├── .gitignore ├── test_assets ├── root_file.txt ├── root_file_excluded.txt ├── test_config_no_exclude.json ├── test_config_no_paths.json └── test_config.json ├── src ├── constants.rs ├── bin │ └── tmbliss.rs ├── logger.rs ├── test_utils.rs ├── xattr.rs ├── directory_iterator.rs ├── recursive_directory_iterator.rs ├── conf.rs ├── git.rs ├── time_machine.rs ├── args.rs ├── filetree.rs └── lib.rs ├── pretest.sh ├── Makefile ├── Cargo.toml ├── update_tap.rb ├── LICENCE.txt ├── readme.md ├── .devcontainer └── devcontainer.json ├── cli.md ├── .github └── workflows │ └── release.yml ├── tests └── main.rs └── Cargo.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /test_assets/root_file.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_assets/root_file_excluded.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_assets/test_config_no_exclude.json: -------------------------------------------------------------------------------- 1 | { 2 | "paths": ["./test_assets/test_dir"], 3 | "dry_run": true 4 | } 5 | -------------------------------------------------------------------------------- /test_assets/test_config_no_paths.json: -------------------------------------------------------------------------------- 1 | { 2 | "allowlist_path": ["./test_assets/test_dir"], 3 | "dry_run": true 4 | } 5 | -------------------------------------------------------------------------------- /src/constants.rs: -------------------------------------------------------------------------------- 1 | pub static TMUTIL_ATTR: &str = "com.apple.metadata:com_apple_backup_excludeItem"; 2 | pub static TMBLISS_FILE: &str = ".tmbliss"; 3 | -------------------------------------------------------------------------------- /test_assets/test_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "paths": ["./test_assets/test_dir"], 3 | "allowlist_glob": ["**/.env", "**/.env.*"], 4 | "dry_run": true 5 | } 6 | -------------------------------------------------------------------------------- /pretest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | chown root:wheel test_assets/root_file.txt 4 | chown root:wheel test_assets/root_file_excluded.txt 5 | tmutil addexclusion test_assets/root_file_excluded.txt -------------------------------------------------------------------------------- /src/bin/tmbliss.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use tmbliss::{Args, TMBliss}; 5 | 6 | fn main() -> Result<()> { 7 | let args = Args::parse(); 8 | let command = args.command; 9 | 10 | TMBliss::run(command)?; 11 | Ok(()) 12 | } 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test_dir_edit test_dir_save test_dir_abort cli.md 2 | 3 | test_dir_edit: 4 | unzip test_assets/test_dir.zip -d test_assets 5 | 6 | test_dir_save: 7 | rm -f test_assets/test_dir.zip && cd test_assets && zip -r test_dir.zip test_dir && rm -rf test_dir 8 | 9 | test_dir_abort: 10 | rm -rf test_assets/test_dir 11 | 12 | cli.md: 13 | cargo run -- markdown-help > cli.md -------------------------------------------------------------------------------- /src/logger.rs: -------------------------------------------------------------------------------- 1 | pub type LoggerFilter = dyn Fn(&str, &str) -> bool; 2 | 3 | pub struct Logger<'a> { 4 | pub filter: Option<&'a LoggerFilter>, 5 | } 6 | 7 | impl Logger<'_> { 8 | pub fn log(&self, label: &str, message: &str) { 9 | if let Some(filter) = self.filter { 10 | if filter(label, message) { 11 | return; 12 | } 13 | } 14 | println!("{}: {}", label, message); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tmbliss" 3 | version = "0.0.1-beta.16" 4 | edition = "2021" 5 | 6 | [[bin]] 7 | name = "tmbliss" 8 | 9 | [dependencies] 10 | clap = { version = "4.2.5", features = ["derive"] } 11 | clap-markdown = "0.1.3" 12 | glob-match = "0.2.1" 13 | serde = { version = "1.0", features = ["derive"] } 14 | serde_json = "1.0" 15 | anyhow = "1.0" 16 | chrono = "0.4.24" 17 | regex = "1" 18 | ignore = "0.4.23" 19 | xattr = "1.5.1" 20 | 21 | [dev-dependencies] 22 | uuid = { version = "1.3.2", features = ["v4", "fast-rng"] } 23 | assert_matches = "1.5.0" 24 | test-case = "3.3.1" 25 | -------------------------------------------------------------------------------- /update_tap.rb: -------------------------------------------------------------------------------- 1 | def main 2 | file = ARGV[0] 3 | version = ARGV[1] 4 | sha = ARGV[2] 5 | url = "https://github.com/Reeywhaar/tmbliss/releases/download/v#{version}/homebrew.zip" 6 | 7 | content = File.read(file) 8 | content = content.gsub(/# url-placeholder\n.*$/, "# url-placeholder\n url \"#{url}\"") 9 | content = content.gsub(/# version-placeholder\n.*$/, "# version-placeholder\n version \"#{version}\"") 10 | content = content.gsub(/# sha256-placeholder\n.*$/, "# sha256-placeholder\n sha256 \"#{sha}\"") 11 | content = content.gsub(/^\s+revision.*$/, "") 12 | 13 | File.open(file, "w") do |f| 14 | f.write(content) 15 | end 16 | end 17 | 18 | main -------------------------------------------------------------------------------- /src/test_utils.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env::temp_dir, 3 | fs, 4 | path::{Path, PathBuf}, 5 | }; 6 | 7 | use uuid::Uuid; 8 | 9 | pub struct TestDir { 10 | path: PathBuf, 11 | } 12 | 13 | impl TestDir { 14 | #[allow(clippy::new_without_default)] 15 | pub fn new() -> Self { 16 | let s = Self { 17 | path: temp_dir().join(format!("test_assets_{}", Uuid::new_v4())), 18 | }; 19 | if s.path.exists() { 20 | fs::remove_dir_all(&s.path).unwrap(); 21 | } 22 | fs::create_dir_all(&s.path).unwrap(); 23 | s 24 | } 25 | 26 | pub fn path(&self) -> &Path { 27 | &self.path 28 | } 29 | 30 | pub fn join(&self, path: impl AsRef) -> PathBuf { 31 | self.path.join(path) 32 | } 33 | } 34 | 35 | impl Drop for TestDir { 36 | fn drop(&mut self) { 37 | fs::remove_dir_all(&self.path).unwrap(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/xattr.rs: -------------------------------------------------------------------------------- 1 | use std::{io, process::Command}; 2 | 3 | pub struct Xattr {} 4 | 5 | impl Xattr { 6 | pub fn set_xattr(path: &str, attribute: &str) -> Result<(), io::Error> { 7 | let result = Command::new("/usr/bin/xattr") 8 | .arg("-w") 9 | .arg(attribute) 10 | .arg("1") 11 | .arg(path) 12 | .output()?; 13 | 14 | if !result.status.success() { 15 | return Err(io::Error::new(io::ErrorKind::Other, "Cannot set xattr")); 16 | } 17 | 18 | Ok(()) 19 | } 20 | 21 | pub fn unset_xattr(path: &str, attribute: &str) -> Result<(), io::Error> { 22 | let result = Command::new("/usr/bin/xattr") 23 | .arg("-d") 24 | .arg(attribute) 25 | .arg(path) 26 | .output()?; 27 | 28 | if !result.status.success() { 29 | return Err(io::Error::new(io::ErrorKind::Other, "Cannot unset xattr")); 30 | } 31 | 32 | Ok(()) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mikhail Vyrtsev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /src/directory_iterator.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use anyhow::{anyhow, Context, Result}; 7 | 8 | pub struct DirectoryIterator<'a> { 9 | pub path: &'a Path, 10 | pub exclude: Option<&'a dyn for<'b> Fn(&'b Path) -> bool>, 11 | } 12 | 13 | impl DirectoryIterator<'_> { 14 | pub fn list(&self) -> Result> { 15 | let mut paths = Vec::new(); 16 | 17 | if !self.path.is_dir() { 18 | return Err(anyhow!("Path is not a directory")); 19 | } 20 | 21 | let entries = fs::read_dir(self.path) 22 | .with_context(|| format!("Can't read dir {}", self.path.display()))?; 23 | 24 | for entry in entries { 25 | let entry = entry?.path(); 26 | 27 | if let Some(excluder) = self.exclude { 28 | if excluder(&entry) { 29 | continue; 30 | } 31 | } 32 | 33 | if !entry.is_symlink() && entry.is_dir() { 34 | paths.push(entry.to_path_buf()); 35 | } 36 | } 37 | 38 | Ok(paths) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # TMBliss 2 | 3 | [**Cli Documentation**](./cli.md) 4 | 5 | TMBliss is a rust written cli utility for MacOS heavily inspired by [tmignore](https://github.com/samuelmeuli/tmignore). It adds exclusions for derived development and other undesired files such as `node_modules` directory or build output to **Time Machine** backup. 6 | 7 | ## Installation 8 | 9 | `tmbliss` can be installed via homebrew 10 | 11 | ``` 12 | brew install reeywhaar/tap/tmbliss 13 | ``` 14 | 15 | Or by manually downloading archive from [releases](./releases/latest) page. `silicon.zip` is for silicon (M1) cpus and `intel.zip` is for intel. 16 | 17 | ## Basic Usage 18 | 19 | To show which files would be excluded from given directory you can run: 20 | 21 | ``` 22 | tmbliss run --path ~/Dev --allowlist-glob "**/.env" --dry-run 23 | ``` 24 | 25 | Every option can be seen in [Cli Documentation](./cli.md) 26 | 27 | ## .tmbliss file 28 | You can create `.tmbliss` file, that acts as `.gitignore` in reverse. You can declare globs to be force included into TimeMachine backup even if it is defined in `.gitignore`. Kinda same as `--allowlist-glob` but per directory 29 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/debian 3 | { 4 | "name": "tmbliss-dvcnt", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/base:bookworm", 7 | "features": { 8 | "ghcr.io/devcontainers/features/rust:1": { 9 | "version": "latest", 10 | "profile": "default", 11 | "components": "rust-analyzer,rust-src,rustfmt,clippy" 12 | } 13 | }, 14 | "workspaceFolder": "/workspace", 15 | "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind", 16 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 17 | // "forwardPorts": [], 18 | // Configure tool-specific properties. 19 | "customizations": { 20 | "vscode": { 21 | "extensions": [ 22 | "rust-lang.rust-analyzer" 23 | ] 24 | } 25 | }, 26 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 27 | // "remoteUser": "root" 28 | "runArgs": [ 29 | "--name", 30 | "tmbliss-dvcnt" 31 | ] 32 | } -------------------------------------------------------------------------------- /src/recursive_directory_iterator.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use anyhow::{Context, Result}; 7 | 8 | pub struct RecursiveDirectoryIterator<'a> { 9 | pub path: &'a Path, 10 | pub op: &'a dyn for<'b> Fn(&'b PathBuf) -> Result, 11 | } 12 | 13 | impl RecursiveDirectoryIterator<'_> { 14 | pub fn iterate(&self) -> Result<()> { 15 | for entry in fs::read_dir(self.path) 16 | .with_context(|| format!("Can't read dir {}", self.path.display()))? 17 | { 18 | let entry = entry?.path(); 19 | 20 | let should_continue = (self.op)(&entry) 21 | .with_context(|| format!("Can't process path {}", entry.display()))?; 22 | 23 | if should_continue && !entry.is_symlink() && entry.is_dir() { 24 | let iterator = RecursiveDirectoryIterator { 25 | path: &entry, 26 | op: self.op, 27 | }; 28 | iterator.iterate()?; 29 | } 30 | } 31 | 32 | Ok(()) 33 | } 34 | } 35 | 36 | #[cfg(test)] 37 | mod tests { 38 | use std::{cell::RefCell, rc::Rc}; 39 | 40 | use crate::filetree::FileTree; 41 | 42 | use super::*; 43 | 44 | #[test] 45 | fn it_works_recursively() { 46 | let filetree = FileTree::new_test_repo(); 47 | 48 | let fmap = filetree.create(); 49 | 50 | let dir = fmap.get("__workspace").unwrap(); 51 | 52 | let paths = Rc::new(RefCell::new(Vec::::new())); 53 | 54 | let iterator = RecursiveDirectoryIterator { 55 | path: dir, 56 | op: &|path| { 57 | let paths = paths.clone(); 58 | let mut paths = paths.try_borrow_mut().unwrap(); 59 | paths.push(path.clone()); 60 | Ok(true) 61 | }, 62 | }; 63 | 64 | iterator.iterate().unwrap(); 65 | 66 | assert_eq!(paths.clone().try_borrow().unwrap().len(), 42); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/conf.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::File, io::BufReader}; 2 | 3 | use anyhow::{Context, Result}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Serialize, Deserialize, Clone)] 7 | pub struct Conf { 8 | pub paths: Vec, 9 | 10 | #[serde(default)] 11 | pub allowlist_glob: Vec, 12 | 13 | #[serde(default)] 14 | pub allowlist_path: Vec, 15 | 16 | #[serde(default)] 17 | pub skip_glob: Vec, 18 | 19 | #[serde(default)] 20 | pub skip_path: Vec, 21 | 22 | /// Should program run in dry run mode. No changes made 23 | #[serde(default)] 24 | pub dry_run: bool, 25 | 26 | /// Should program skip errors (adding exclusion) 27 | #[serde(default)] 28 | pub skip_errors: bool, 29 | 30 | /// Paths that should be removed from time machine backup 31 | #[serde(default)] 32 | pub exclude_paths: Vec, 33 | } 34 | 35 | impl Default for Conf { 36 | fn default() -> Self { 37 | Self { 38 | paths: Vec::new(), 39 | allowlist_glob: Vec::new(), 40 | allowlist_path: Vec::new(), 41 | skip_glob: Vec::new(), 42 | skip_path: Vec::new(), 43 | dry_run: true, 44 | skip_errors: true, 45 | exclude_paths: Vec::new(), 46 | } 47 | } 48 | } 49 | 50 | impl Conf { 51 | pub fn parse(path: &str) -> Result { 52 | let file = 53 | File::open(path).with_context(|| format!("Cannot open configuration at {}", path))?; 54 | let reader = BufReader::new(file); 55 | serde_json::from_reader(reader) 56 | .with_context(|| format!("Cannot create reader for path {}", path)) 57 | } 58 | } 59 | 60 | #[cfg(test)] 61 | mod tests { 62 | #[test] 63 | fn it_parses_config() { 64 | let conf = super::Conf::parse("./test_assets/test_config.json").unwrap(); 65 | 66 | assert_eq!(conf.paths, ["./test_assets/test_dir"]); 67 | assert_eq!(conf.allowlist_glob.len(), 2); 68 | assert_eq!(conf.allowlist_glob, ["**/.env", "**/.env.*"]); 69 | assert!(conf.dry_run); 70 | } 71 | 72 | #[test] 73 | fn it_fails_if_no_paths_provided() { 74 | let conf = super::Conf::parse("./test_assets/test_config_no_paths.json"); 75 | 76 | assert!(conf.is_err()); 77 | } 78 | 79 | #[test] 80 | fn it_parses_config_with_missing_excludes() { 81 | let conf = super::Conf::parse("./test_assets/test_config_no_exclude.json").unwrap(); 82 | 83 | assert_eq!(conf.paths, ["./test_assets/test_dir"]); 84 | assert_eq!(conf.allowlist_glob.len(), 0); 85 | assert!(conf.dry_run); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/git.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use anyhow::Result; 7 | use ignore::gitignore::{gitconfig_excludes_path, GitignoreBuilder}; 8 | 9 | pub struct Git { 10 | pub path: PathBuf, 11 | } 12 | 13 | impl Git { 14 | /// Lists all files that are ignored by git 15 | pub fn get_ignores_list(&self) -> Result> { 16 | if !self.path.is_dir() { 17 | return Err(anyhow::anyhow!("Path is not a directory")); 18 | } 19 | 20 | let mut gitignore_builder = GitignoreBuilder::new(&self.path); 21 | if let Some(gitconfig_path) = gitconfig_excludes_path() { 22 | if gitconfig_path.exists() { 23 | let err = gitignore_builder.add(gitconfig_path); 24 | if let Some(err) = err { 25 | return Err(err.into()); 26 | } 27 | } 28 | } 29 | 30 | let mut ignored: Vec = vec![]; 31 | 32 | fn visitor( 33 | path: &Path, 34 | gitignore_builder: &GitignoreBuilder, 35 | ignored: &mut Vec, 36 | ) -> Result<()> { 37 | if path.ends_with(".git") { 38 | return Ok(()); 39 | } 40 | 41 | let is_dir = path.is_dir(); 42 | let mut gitignore_builder = gitignore_builder.clone(); 43 | let gitignore_file = path.join(".gitignore"); 44 | if gitignore_file.exists() { 45 | let err = gitignore_builder.add(path.join(".gitignore")); 46 | if let Some(err) = err { 47 | return Err(err.into()); 48 | } 49 | } 50 | let gitignore = gitignore_builder.build()?; 51 | if gitignore.matched(path, is_dir).is_ignore() { 52 | ignored.push(path.canonicalize()?); 53 | return Ok(()); 54 | } 55 | if is_dir { 56 | for entry in fs::read_dir(path)? { 57 | let entry = entry?; 58 | visitor(&entry.path(), &gitignore_builder, ignored)?; 59 | } 60 | } 61 | Ok(()) 62 | } 63 | 64 | visitor(&self.path, &gitignore_builder, &mut ignored)?; 65 | 66 | ignored.sort(); 67 | Ok(ignored) 68 | } 69 | 70 | /// Checks if a directory is a git service directory (".git") 71 | pub fn is_git(path: &Path) -> bool { 72 | path.ends_with(".git") 73 | } 74 | } 75 | 76 | #[cfg(test)] 77 | mod tests { 78 | use std::env::current_dir; 79 | 80 | use crate::filetree::FileTree; 81 | 82 | use super::*; 83 | 84 | #[test] 85 | fn it_lists_ignored_files() { 86 | let filetree = FileTree::new_test_repo(); 87 | 88 | let fmap = filetree.create(); 89 | 90 | let dir = fmap.get("__workspace").unwrap(); 91 | assert_eq!(dir.read_dir().unwrap().count(), 9); 92 | 93 | let git = Git { path: dir.clone() }; 94 | 95 | let mut list = git.get_ignores_list().unwrap(); 96 | list.sort(); 97 | 98 | let mut result = [ 99 | dir.join(".excluded_glob"), 100 | dir.join("excluded_path"), 101 | dir.join("not_excluded_path"), 102 | dir.join("nested_dir/excluded_file.txt"), 103 | dir.join("nested_dir_with_single_file/excluded_file.txt"), 104 | dir.join("directory_with_subgitignore/subignore.txt"), 105 | ] 106 | .map(|p| p.canonicalize().unwrap()) 107 | .to_vec(); 108 | result.sort(); 109 | 110 | assert_eq!(list, result); 111 | } 112 | 113 | #[test] 114 | fn it_check_if_directory_is_git() { 115 | assert!(Git::is_git(¤t_dir().unwrap().join(".git"))); 116 | } 117 | 118 | #[test] 119 | fn it_check_if_directory_is_not_git() { 120 | assert!(!Git::is_git(¤t_dir().unwrap().join("tests"))); 121 | assert!(!Git::is_git(¤t_dir().unwrap().join("tests.git"))); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/time_machine.rs: -------------------------------------------------------------------------------- 1 | use std::{error::Error, fmt::Display, path::Path}; 2 | 3 | use anyhow::Result; 4 | 5 | use crate::constants::TMUTIL_ATTR; 6 | 7 | pub struct TimeMachine {} 8 | 9 | impl TimeMachine { 10 | pub fn add_exclusion(path: &Path) -> Result<(), TimeMachineError> { 11 | xattr::set(path, TMUTIL_ATTR, b"1").map_err(Self::parse_error) 12 | } 13 | 14 | pub fn remove_exclusion(path: &Path) -> Result<(), TimeMachineError> { 15 | xattr::remove(path, TMUTIL_ATTR).map_err(Self::parse_error) 16 | } 17 | 18 | pub fn is_excluded(path: &Path) -> Result { 19 | Ok(xattr::get(path, TMUTIL_ATTR) 20 | .map_err(Self::parse_error)? 21 | .is_some()) 22 | } 23 | 24 | pub fn is_excluded_deep(path: &Path) -> Result { 25 | let mut p = path.to_path_buf(); 26 | loop { 27 | if Self::is_excluded(&p)? { 28 | return Ok(true); 29 | } 30 | let parent = p.parent(); 31 | if parent.is_none() { 32 | break; 33 | } 34 | p = parent.unwrap().to_path_buf(); 35 | } 36 | Ok(false) 37 | } 38 | 39 | fn parse_error(err: std::io::Error) -> TimeMachineError { 40 | match err.raw_os_error() { 41 | Some(2) => TimeMachineError::FileNotFound(Some(Box::new(err))), 42 | Some(13) => TimeMachineError::FileInaccessible(Some(Box::new(err))), 43 | status => TimeMachineError::Unknown( 44 | match status { 45 | Some(code) => format!("Error with status code {}", code), 46 | None => "Error with unknown status code".to_string(), 47 | }, 48 | Some(Box::new(err)), 49 | ), 50 | } 51 | } 52 | } 53 | 54 | #[derive(Debug)] 55 | pub enum TimeMachineError { 56 | FileNotFound(Option>), 57 | FileInaccessible(Option>), 58 | Unknown(String, Option>), 59 | } 60 | 61 | impl Error for TimeMachineError { 62 | fn description(&self) -> &str { 63 | match &self { 64 | TimeMachineError::FileNotFound(_) => "File not found", 65 | TimeMachineError::FileInaccessible(_) => "File inaccessible", 66 | TimeMachineError::Unknown(_description, _) => "Unknown error", 67 | } 68 | } 69 | 70 | fn cause(&self) -> Option<&dyn Error> { 71 | match &self { 72 | TimeMachineError::FileNotFound(Some(e)) => Some(e.as_ref()), 73 | TimeMachineError::FileInaccessible(Some(e)) => Some(e.as_ref()), 74 | TimeMachineError::Unknown(_, Some(e)) => Some(e.as_ref()), 75 | _ => None, 76 | } 77 | } 78 | } 79 | 80 | impl Display for TimeMachineError { 81 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 82 | match &self { 83 | TimeMachineError::FileNotFound(_) => write!(f, "File not found"), 84 | TimeMachineError::FileInaccessible(_) => write!(f, "File inaccessible"), 85 | TimeMachineError::Unknown(description, _) => { 86 | write!(f, "Unknown error: {}", description) 87 | } 88 | } 89 | } 90 | } 91 | 92 | #[cfg(test)] 93 | mod tests { 94 | use assert_matches::assert_matches; 95 | use uuid::Uuid; 96 | 97 | use crate::test_utils::TestDir; 98 | 99 | use super::*; 100 | 101 | use std::fs::{self, File}; 102 | 103 | #[test] 104 | fn it_sets_xattr() { 105 | let workspace = TestDir::new(); 106 | let pathstr = workspace.join(format!("./text-{}.txt", Uuid::new_v4())); 107 | File::create(pathstr.clone()).unwrap(); 108 | TimeMachine::add_exclusion(&pathstr).unwrap(); 109 | 110 | assert!(TimeMachine::is_excluded(&pathstr).unwrap()); 111 | 112 | fs::remove_file(pathstr).unwrap(); 113 | } 114 | 115 | #[test] 116 | fn it_throws_inaccessible_if_cant_set_xattr() { 117 | let path = Path::new("./test_assets/root_file.txt"); 118 | let result = TimeMachine::add_exclusion(path); 119 | 120 | assert!(!TimeMachine::is_excluded(path).unwrap()); 121 | assert_matches!(result, Err(TimeMachineError::FileInaccessible(_))); 122 | } 123 | 124 | #[test] 125 | fn it_throws_not_found_if_cant_set_xattr() { 126 | let path = Path::new("./test_assets/not_a_file.txt"); 127 | let result = TimeMachine::add_exclusion(path); 128 | 129 | assert_matches!(result, Err(TimeMachineError::FileNotFound(_))); 130 | } 131 | 132 | #[test] 133 | fn it_removes_xattr() { 134 | let cwd = TestDir::new(); 135 | let pathstr = cwd.join(format!("./text-{}.txt", Uuid::new_v4())); 136 | File::create(pathstr.clone()).unwrap(); 137 | TimeMachine::add_exclusion(&pathstr).unwrap(); 138 | 139 | assert!(TimeMachine::is_excluded(&pathstr).unwrap()); 140 | 141 | TimeMachine::remove_exclusion(&pathstr).unwrap(); 142 | 143 | assert!(!TimeMachine::is_excluded(&pathstr).unwrap()); 144 | 145 | fs::remove_file(pathstr).unwrap(); 146 | } 147 | 148 | #[test] 149 | fn it_throws_inaccessible_if_cant_remove_xattr() { 150 | let path = Path::new("./test_assets/root_file_excluded.txt"); 151 | let result = TimeMachine::remove_exclusion(path); 152 | 153 | assert!(TimeMachine::is_excluded(path).unwrap()); 154 | assert_matches!(result, Err(TimeMachineError::FileInaccessible(_))); 155 | } 156 | 157 | #[test] 158 | fn it_throws_not_found_if_cant_remove_xattr() { 159 | let path = Path::new("./test_assets/not_a_file.txt"); 160 | let result = TimeMachine::remove_exclusion(path); 161 | 162 | assert_matches!(result, Err(TimeMachineError::FileNotFound(_))); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /cli.md: -------------------------------------------------------------------------------- 1 | # Command-Line Help for `tmbliss` 2 | 3 | This document contains the help content for the `tmbliss` command-line program. 4 | 5 | **Command Overview:** 6 | 7 | * [`tmbliss`↴](#tmbliss) 8 | * [`tmbliss run`↴](#tmbliss-run) 9 | * [`tmbliss list`↴](#tmbliss-list) 10 | * [`tmbliss conf`↴](#tmbliss-conf) 11 | * [`tmbliss service`↴](#tmbliss-service) 12 | * [`tmbliss reset`↴](#tmbliss-reset) 13 | * [`tmbliss show-excluded`↴](#tmbliss-show-excluded) 14 | * [`tmbliss markdown-help`↴](#tmbliss-markdown-help) 15 | 16 | ## `tmbliss` 17 | 18 | **Usage:** `tmbliss ` 19 | 20 | ###### **Subcommands:** 21 | 22 | * `run` — Runs command in given directory and marks files as excluded from backup 23 | * `list` — Runs command in given directory and shows files which would be excluded from backup. Alias for 'run --dry-run' 24 | * `conf` — Runs command with a configuration file 25 | * `service` — Same as 'conf' but with logging suitable for a service 26 | * `reset` — Reset all exclusions in given directory 27 | * `show-excluded` — Show excluded files starting from given directory: Alias for 'reset --dry-run' 28 | * `markdown-help` — Generate markdown help 29 | 30 | 31 | 32 | ## `tmbliss run` 33 | 34 | Runs command in given directory and marks files as excluded from backup 35 | 36 | **Usage:** `tmbliss run [OPTIONS]` 37 | 38 | ###### **Options:** 39 | 40 | * `--path ` — Directory paths to run the command in. [--path ... --path ...] 41 | * `--dry-run` — Dry run. Only show list of files that would be excluded 42 | 43 | Default value: `false` 44 | * `--allowlist-glob ` — Force include file globs into backup. Allows multiple globs. [--allowlist-glob ... --allowlist-glob ...] 45 | * `--allowlist-path ` — Force include file paths into backup. Allows multiple paths. [--allowlist-path ./1 --allowlist-path ./2] 46 | * `--skip-glob ` — Skip file globs from checking. Difference with allowlist is that if condition met than program wont do processing for child directories. Allows multiple globs. [--skip-glob ... --skip-glob ...] 47 | * `--skip-path ` — Skip file paths from checking. Difference with allowlist is that if condition met than program wont do processing for child directories. Allows multiple paths. [--skip-path ./1 --skip-path ./2] 48 | * `--skip-errors` — Skip errors when adding or checking exclusion. In case of for example insufficient permissions 49 | 50 | Default value: `true` 51 | * `--exclude-path ` — Path that should be removed from time machine backup. Allows multiple paths. [--exclude-path ./1 --exclude-path ./2] 52 | 53 | 54 | 55 | ## `tmbliss list` 56 | 57 | Runs command in given directory and shows files which would be excluded from backup. Alias for 'run --dry-run' 58 | 59 | **Usage:** `tmbliss list [OPTIONS]` 60 | 61 | ###### **Options:** 62 | 63 | * `--path ` — Directory paths to run the command in. [--path ... --path ...] 64 | * `--allowlist-glob ` — Force include file globs into backup. [--allowlist-glob ... --allowlist-glob ...] 65 | * `--allowlist-path ` — Force include file paths into backup. [--allowlist-path ./1 --allowlist-path ./2] 66 | * `--skip-glob ` — Skip file globs from checking. Difference with allowlist is that if condition met than program won't do processing for child directories [--skip-glob ... --skip-glob ...] 67 | * `--skip-path ` — Skip file paths from checking. Difference with allowlist is that if condition met than program won't do processing for child directories [--skip-path ./1 --skip-path ./2] 68 | * `--skip-errors` — Skip errors when adding or checking exclusion. In case of for example insufficient permissions 69 | 70 | Default value: `true` 71 | * `--exclude-path ` — Path that should be removed from time machine backup 72 | 73 | 74 | 75 | ## `tmbliss conf` 76 | 77 | Runs command with a configuration file 78 | 79 | **Usage:** `tmbliss conf [OPTIONS] --path ` 80 | 81 | ###### **Options:** 82 | 83 | * `--path ` — Configuration file path 84 | * `--dry-run ` — Dry run. Overrides configuration file option 85 | 86 | Possible values: `true`, `false` 87 | 88 | 89 | 90 | 91 | ## `tmbliss service` 92 | 93 | Same as 'conf' but with logging suitable for a service 94 | 95 | **Usage:** `tmbliss service [OPTIONS] --path ` 96 | 97 | ###### **Options:** 98 | 99 | * `--path ` — Configuration file path 100 | * `--dry-run ` — Dry run. Overrides configuration file option 101 | 102 | Possible values: `true`, `false` 103 | 104 | 105 | 106 | 107 | ## `tmbliss reset` 108 | 109 | Reset all exclusions in given directory 110 | 111 | **Usage:** `tmbliss reset [OPTIONS] --path ` 112 | 113 | ###### **Options:** 114 | 115 | * `--path ` — Directory path 116 | * `--dry-run` — Dry run. Only show list of files that would be reset 117 | 118 | Default value: `false` 119 | * `--allowlist-glob ` — Skip reset for glob matched files. [--allowlist-glob ... --allowlist-glob ...] 120 | * `--allowlist-path ` — Skip reset for matched paths. [--allowlist-path ./1 --allowlist-path ./2] 121 | 122 | 123 | 124 | ## `tmbliss show-excluded` 125 | 126 | Show excluded files starting from given directory: Alias for 'reset --dry-run' 127 | 128 | **Usage:** `tmbliss show-excluded [OPTIONS] --path ` 129 | 130 | ###### **Options:** 131 | 132 | * `--path ` — Directory path 133 | * `--allowlist-glob ` — Skip reset for glob matched files. [--allowlist-glob ... --allowlist-glob ...] 134 | * `--allowlist-path ` — Skip reset for matched paths. [--allowlist-path ./1 --allowlist-path ./2] 135 | 136 | 137 | 138 | ## `tmbliss markdown-help` 139 | 140 | Generate markdown help 141 | 142 | **Usage:** `tmbliss markdown-help` 143 | 144 | 145 | 146 |
147 | 148 | 149 | This document was generated automatically by 150 | clap-markdown. 151 | 152 | 153 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*.*.*" 7 | 8 | jobs: 9 | build: 10 | strategy: 11 | matrix: 12 | toolchain: 13 | [ 14 | { arch: aarch64-apple-darwin, name: silicon }, 15 | { arch: x86_64-apple-darwin, name: intel }, 16 | ] 17 | name: Build ${{ matrix.toolchain.name }} 18 | runs-on: macos-latest 19 | steps: 20 | - uses: actions/checkout@v2 21 | - uses: actions-rs/toolchain@v1 22 | with: 23 | toolchain: stable 24 | target: ${{ matrix.toolchain.arch }} 25 | override: true 26 | - uses: Swatinem/rust-cache@v2 27 | with: 28 | prefix-key: ${{ matrix.toolchain.arch }} 29 | - name: Setup tests 30 | run: sudo bash pretest.sh 31 | - name: Test 32 | run: cargo test --release 33 | 34 | - name: "Configure certificates" 35 | run: > 36 | echo $DISTRIBUTION_CERT_BASE_64 | base64 --decode > cert.p12 && 37 | security create-keychain -p $KEYCHAIN_PASS $KEYCHAIN && 38 | security default-keychain -s ~/Library/Keychains/$KEYCHAIN-db && 39 | security set-keychain-settings $KEYCHAIN && 40 | security list-keychains -s $KEYCHAIN && 41 | security list-keychains && 42 | security unlock-keychain -p $KEYCHAIN_PASS $KEYCHAIN && 43 | security import ./cert.p12 -k $KEYCHAIN -P $DISTRIBUTION_CERT_PASS -A -T /usr/bin/codesign -T /usr/bin/security && 44 | security set-key-partition-list -S apple-tool:,apple: -s -k $KEYCHAIN_PASS $KEYCHAIN && 45 | security find-identity -p codesigning -v 46 | env: 47 | KEYCHAIN: "def.keychain" 48 | KEYCHAIN_PASS: "hmmmm" 49 | DISTRIBUTION_CERT_BASE_64: ${{ secrets.DISTRIBUTION_CERT_BASE_64 }} 50 | DISTRIBUTION_CERT_PASS: ${{ secrets.DISTRIBUTION_CERT_PASS }} 51 | 52 | - name: "Configure notarytool" 53 | run: > 54 | xcrun notarytool store-credentials notarytool --apple-id $APPLE_ID --team-id $TEAM_ID --password $NOTARY_TOOL_PASS 55 | env: 56 | APPLE_ID: ${{ secrets.APPLE_ID }} 57 | NOTARY_TOOL_PASS: ${{ secrets.NOTARY_TOOL_PASS }} 58 | TEAM_ID: ${{ secrets.TEAM_ID }} 59 | 60 | - uses: actions-rs/cargo@v1 61 | with: 62 | command: build 63 | args: --release --all-features --target ${{ matrix.toolchain.arch }} 64 | 65 | - name: Create signed binary 66 | run: | 67 | mv target/${{ matrix.toolchain.arch }}/release/tmbliss ./tmbliss 68 | codesign -s "$SIGNING_IDENTITY" --deep -v -f -o runtime ./tmbliss 69 | env: 70 | SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }} 71 | 72 | - name: Create archive for notarization 73 | run: zip -r notary.zip tmbliss 74 | 75 | - name: Notarize app 76 | run: xcrun notarytool submit notary.zip --keychain-profile notarytool --wait 77 | 78 | - name: Create release folder 79 | run: | 80 | mkdir output && mv ./tmbliss output/ && \ 81 | chmod +x output/tmbliss && mv LICENCE.txt output/ && mv output ${{ matrix.toolchain.name }} 82 | 83 | - name: Create zip 84 | run: zip -qq -r ${{ matrix.toolchain.name }}.zip ${{ matrix.toolchain.name }} 85 | 86 | - uses: actions/upload-artifact@v4 87 | with: 88 | name: ${{ matrix.toolchain.name }} 89 | path: ${{ matrix.toolchain.name }}.zip 90 | 91 | release: 92 | name: Make release 93 | runs-on: ubuntu-latest 94 | needs: build 95 | steps: 96 | - uses: actions/checkout@v2 97 | - uses: actions/download-artifact@v4 98 | with: 99 | path: ~/artifacts 100 | - name: Move artifacts 101 | run: mv ~/artifacts/**/* ./ 102 | - name: Create homebrew package 103 | run: | 104 | unzip silicon.zip && \ 105 | unzip intel.zip && \ 106 | mkdir homebrew && \ 107 | mv silicon homebrew/ && \ 108 | mv intel homebrew/ && \ 109 | zip -qq -r homebrew.zip homebrew 110 | - uses: actions/upload-artifact@v4 111 | with: 112 | name: homebrew 113 | path: homebrew.zip 114 | - name: "Create Release" 115 | uses: softprops/action-gh-release@v2 116 | env: 117 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 118 | with: 119 | files: | 120 | silicon.zip 121 | intel.zip 122 | homebrew.zip 123 | 124 | bump_homebrew: 125 | name: Bump homebrew formula 126 | runs-on: ubuntu-latest 127 | needs: release 128 | steps: 129 | - uses: actions/checkout@v2 130 | - uses: actions/download-artifact@v4 131 | with: 132 | name: homebrew 133 | - name: Set git identity 134 | run: | 135 | git config --global user.email "runner@example.com" 136 | git config --global user.name "runner" 137 | - name: Set sha 138 | run: | 139 | SHA=$(openssl dgst -sha256 homebrew.zip | awk '{print $2}') && \ 140 | echo "TMBLISS_SHA256=$(echo $SHA)" >> $GITHUB_ENV 141 | - name: Set version 142 | run: | 143 | VERSION=$(cat Cargo.toml | grep -o 'version = ".*"' | head -n 1 | cut -c 12- | rev | cut -c2- | rev) && \ 144 | echo "TMBLISS_VERSION=$(echo $VERSION)" >> $GITHUB_ENV 145 | - uses: actions/checkout@v2 146 | with: 147 | ref: main 148 | path: tap 149 | repository: "reeywhaar/homebrew-tap" 150 | token: ${{ secrets.TAP_REPO_TOKEN }} 151 | - name: Update formula 152 | run: ruby update_tap.rb tap/Formula/tmbliss.rb $TMBLISS_VERSION $TMBLISS_SHA256 153 | - name: Commit 154 | run: git add . && git commit -m "tmbliss $TMBLISS_VERSION" 155 | working-directory: tap 156 | - name: Push 157 | run: git push origin main 158 | working-directory: tap 159 | env: 160 | HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.TAP_REPO_TOKEN }} 161 | -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser, Subcommand}; 2 | 3 | #[derive(Parser, Debug)] 4 | #[command(author, version, about, long_about = None)] 5 | pub struct Args { 6 | #[command(subcommand)] 7 | pub command: Command, 8 | } 9 | 10 | #[derive(Subcommand, Debug, PartialEq)] 11 | pub enum Command { 12 | /// Runs command in given directory and marks files as excluded from backup 13 | Run { 14 | /// Directory paths to run the command in. [--path ... --path ...] 15 | #[arg(long)] 16 | path: Vec, 17 | 18 | /// Dry run. Only show list of files that would be excluded 19 | #[arg(long, default_value = "false")] 20 | dry_run: bool, 21 | 22 | /// Force include file globs into backup. 23 | /// Allows multiple globs. [--allowlist-glob ... --allowlist-glob ...] 24 | #[arg(long)] 25 | allowlist_glob: Vec, 26 | 27 | /// Force include file paths into backup. 28 | /// Allows multiple paths. [--allowlist-path ./1 --allowlist-path ./2] 29 | #[arg(long)] 30 | allowlist_path: Vec, 31 | 32 | /// Skip file globs from checking. 33 | /// Difference with allowlist is that if condition 34 | /// met than program wont do processing for child directories. 35 | /// Allows multiple globs. [--skip-glob ... --skip-glob ...] 36 | #[arg(long)] 37 | skip_glob: Vec, 38 | 39 | /// Skip file paths from checking. 40 | /// Difference with allowlist is that if condition 41 | /// met than program wont do processing for child directories. 42 | /// Allows multiple paths. [--skip-path ./1 --skip-path ./2] 43 | #[arg(long)] 44 | skip_path: Vec, 45 | 46 | /// Skip errors when adding or checking exclusion. 47 | /// In case of for example insufficient permissions. 48 | #[arg(long, default_value = "true")] 49 | skip_errors: bool, 50 | 51 | /// Path that should be removed from time machine backup. 52 | /// Allows multiple paths. [--exclude-path ./1 --exclude-path ./2] 53 | #[arg(long)] 54 | exclude_path: Vec, 55 | }, 56 | 57 | /// Runs command in given directory and shows files which would be excluded from backup. Alias for 'run --dry-run' 58 | List { 59 | /// Directory paths to run the command in. [--path ... --path ...] 60 | #[arg(long)] 61 | path: Vec, 62 | 63 | /// Force include file globs into backup. [--allowlist-glob ... --allowlist-glob ...] 64 | #[arg(long)] 65 | allowlist_glob: Vec, 66 | 67 | /// Force include file paths into backup. [--allowlist-path ./1 --allowlist-path ./2] 68 | #[arg(long)] 69 | allowlist_path: Vec, 70 | 71 | /// Skip file globs from checking. 72 | /// Difference with allowlist is that if condition 73 | /// met than program won't do processing for child directories 74 | /// [--skip-glob ... --skip-glob ...] 75 | #[arg(long)] 76 | skip_glob: Vec, 77 | 78 | /// Skip file paths from checking. 79 | /// Difference with allowlist is that if condition 80 | /// met than program won't do processing for child directories 81 | /// [--skip-path ./1 --skip-path ./2] 82 | #[arg(long)] 83 | skip_path: Vec, 84 | 85 | /// Skip errors when adding or checking exclusion. 86 | /// In case of for example insufficient permissions. 87 | #[arg(long, default_value = "true")] 88 | skip_errors: bool, 89 | 90 | /// Path that should be removed from time machine backup 91 | #[arg(long)] 92 | exclude_path: Vec, 93 | }, 94 | 95 | /// Runs command with a configuration file 96 | Conf { 97 | /// Configuration file path 98 | #[arg(long)] 99 | path: String, 100 | 101 | /// Dry run. Overrides configuration file option 102 | #[arg(long)] 103 | dry_run: Option, 104 | }, 105 | 106 | /// Same as 'conf' but with logging suitable for a service 107 | Service { 108 | /// Configuration file path 109 | #[arg(long)] 110 | path: String, 111 | 112 | /// Dry run. Overrides configuration file option 113 | #[arg(long)] 114 | dry_run: Option, 115 | }, 116 | 117 | /// Reset all exclusions in given directory 118 | Reset { 119 | /// Directory path 120 | #[arg(long)] 121 | path: String, 122 | 123 | /// Dry run. Only show list of files that would be reset 124 | #[arg(long, default_value = "false")] 125 | dry_run: bool, 126 | 127 | /// Skip reset for glob matched files. [--allowlist-glob ... --allowlist-glob ...] 128 | #[arg(long)] 129 | allowlist_glob: Vec, 130 | 131 | /// Skip reset for matched paths. [--allowlist-path ./1 --allowlist-path ./2] 132 | #[arg(long)] 133 | allowlist_path: Vec, 134 | }, 135 | /// Show excluded files starting from given directory: Alias for 'reset --dry-run' 136 | ShowExcluded { 137 | /// Directory path 138 | #[arg(long)] 139 | path: String, 140 | 141 | /// Skip reset for glob matched files. [--allowlist-glob ... --allowlist-glob ...] 142 | #[arg(long)] 143 | allowlist_glob: Vec, 144 | 145 | /// Skip reset for matched paths. [--allowlist-path ./1 --allowlist-path ./2] 146 | #[arg(long)] 147 | allowlist_path: Vec, 148 | }, 149 | /// Generate markdown help 150 | MarkdownHelp, 151 | } 152 | 153 | #[cfg(test)] 154 | mod tests { 155 | use crate::*; 156 | use clap::Parser; 157 | 158 | #[test] 159 | fn it_parses_paths() { 160 | let args = Args::parse_from(["tmbliss", "run", "--path", "./1", "--path", "./2"]); 161 | assert_eq!( 162 | args.command, 163 | Command::Run { 164 | path: [String::from("./1"), String::from("./2")].to_vec(), 165 | dry_run: false, 166 | allowlist_glob: vec![], 167 | allowlist_path: vec![], 168 | skip_glob: vec![], 169 | skip_path: vec![], 170 | skip_errors: true, 171 | exclude_path: vec![], 172 | } 173 | ); 174 | } 175 | 176 | #[test] 177 | fn it_parses_excludes() { 178 | let args = Args::parse_from([ 179 | "tmbliss", 180 | "run", 181 | "--path", 182 | "./", 183 | "--dry-run", 184 | "--allowlist-glob", 185 | ".env", 186 | "--allowlist-glob", 187 | ".env.*", 188 | ]); 189 | assert_eq!( 190 | args.command, 191 | Command::Run { 192 | path: [String::from("./")].to_vec(), 193 | dry_run: true, 194 | allowlist_glob: vec![String::from(".env"), String::from(".env.*")], 195 | allowlist_path: vec![], 196 | skip_glob: vec![], 197 | skip_path: vec![], 198 | skip_errors: true, 199 | exclude_path: vec![], 200 | } 201 | ); 202 | } 203 | 204 | #[test] 205 | fn it_parses_allowlist_paths() { 206 | let args = Args::parse_from([ 207 | "tmbliss", 208 | "run", 209 | "--path", 210 | "./", 211 | "--dry-run", 212 | "--allowlist-path", 213 | "./1", 214 | "--allowlist-path", 215 | "./2", 216 | ]); 217 | assert_eq!( 218 | args.command, 219 | Command::Run { 220 | path: [String::from("./")].to_vec(), 221 | dry_run: true, 222 | allowlist_glob: vec![], 223 | allowlist_path: vec![String::from("./1"), String::from("./2")], 224 | skip_glob: vec![], 225 | skip_path: vec![], 226 | skip_errors: true, 227 | exclude_path: vec![], 228 | } 229 | ); 230 | } 231 | 232 | #[test] 233 | fn it_overrides_dry_run_in_conf_mode() { 234 | let args = Args::parse_from(["tmbliss", "conf", "--path", "./conf.json"]); 235 | assert_eq!( 236 | args.command, 237 | Command::Conf { 238 | path: "./conf.json".to_string(), 239 | dry_run: None 240 | } 241 | ); 242 | { 243 | let args = Args::parse_from([ 244 | "tmbliss", 245 | "conf", 246 | "--path", 247 | "./conf.json", 248 | "--dry-run", 249 | "true", 250 | ]); 251 | assert_eq!( 252 | args.command, 253 | Command::Conf { 254 | path: "./conf.json".to_string(), 255 | dry_run: Some(true) 256 | } 257 | ); 258 | } 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /tests/main.rs: -------------------------------------------------------------------------------- 1 | extern crate tmbliss; 2 | 3 | #[path = "../src/filetree.rs"] 4 | mod filetree; 5 | 6 | use crate::filetree::{FileTree, FileTreeItem}; 7 | 8 | use std::env::current_dir; 9 | use test_case::test_case; 10 | 11 | use tmbliss::{Command, TMBliss, TimeMachine}; 12 | 13 | #[test_case("sec*.txt" ; "sec*.txt")] 14 | #[test_case("/sec*.txt" ; "/sec*.txt")] 15 | #[test_case("/secret.txt" ; "/secret.txt")] 16 | #[test_case("secret.txt" ; "secret.txt")] 17 | fn test_tmbliss_glob_exclusion(case: &str) { 18 | let tree = FileTree::new(vec![ 19 | FileTreeItem::Gitignore { 20 | key: "gitignore".to_string(), 21 | path: "".to_string(), 22 | patterns: vec!["**/devfile.txt".to_string(), "secret.txt".to_string()], 23 | }, 24 | FileTreeItem::TmBliss { 25 | key: "tmbliss".to_string(), 26 | path: "".to_string(), 27 | patterns: vec![case.to_string(), "sub".to_string()], 28 | }, 29 | FileTreeItem::File { 30 | key: "devfile".to_string(), 31 | name: "devfile.txt".to_string(), 32 | is_excluded: false, 33 | }, 34 | FileTreeItem::File { 35 | key: "secret".to_string(), 36 | name: "secret.txt".to_string(), 37 | is_excluded: false, 38 | }, 39 | FileTreeItem::File { 40 | key: "sub/devfile".to_string(), 41 | name: "sub/devfile.txt".to_string(), 42 | is_excluded: false, 43 | }, 44 | ]); 45 | 46 | let hmap = tree.create(); 47 | 48 | let command = Command::Run { 49 | path: vec![hmap 50 | .get("__workspace") 51 | .unwrap() 52 | .to_string_lossy() 53 | .to_string()], 54 | dry_run: false, 55 | allowlist_glob: vec![], 56 | allowlist_path: vec![], 57 | skip_glob: vec![], 58 | skip_path: vec![], 59 | skip_errors: false, 60 | exclude_path: vec![], 61 | }; 62 | let result = TMBliss::run(command); 63 | result.unwrap(); 64 | 65 | assert!( 66 | TimeMachine::is_excluded_deep(hmap.get("devfile").unwrap()).unwrap(), 67 | "Devfile is not excluded" 68 | ); 69 | assert!( 70 | !TimeMachine::is_excluded_deep(hmap.get("secret").unwrap()).unwrap(), 71 | "Secret file is excluded, but it should not be" 72 | ); 73 | assert!( 74 | !TimeMachine::is_excluded_deep(hmap.get("sub/devfile").unwrap()).unwrap(), 75 | "Sub/devfile is excluded, but it should not be" 76 | ); 77 | } 78 | 79 | #[test] 80 | fn test_tmbliss_glob_exclusion_2() { 81 | let tree = FileTree::new(vec![ 82 | FileTreeItem::Gitignore { 83 | key: "gitignore".to_string(), 84 | path: "".to_string(), 85 | patterns: vec!["**/devfile.txt".to_string(), "sub".to_string()], 86 | }, 87 | FileTreeItem::TmBliss { 88 | key: "tmbliss".to_string(), 89 | path: "".to_string(), 90 | patterns: vec!["sub".to_string()], 91 | }, 92 | FileTreeItem::File { 93 | key: "devfile".to_string(), 94 | name: "devfile.txt".to_string(), 95 | is_excluded: false, 96 | }, 97 | FileTreeItem::Directory { 98 | key: "sub".to_string(), 99 | name: "sub".to_string(), 100 | is_excluded: false, 101 | }, 102 | FileTreeItem::File { 103 | key: "sub/devfile".to_string(), 104 | name: "sub/devfile.txt".to_string(), 105 | is_excluded: false, 106 | }, 107 | ]); 108 | 109 | let hmap = tree.create(); 110 | 111 | let command = Command::Run { 112 | path: vec![hmap 113 | .get("__workspace") 114 | .unwrap() 115 | .to_string_lossy() 116 | .to_string()], 117 | dry_run: false, 118 | allowlist_glob: vec![], 119 | allowlist_path: vec![], 120 | skip_glob: vec![], 121 | skip_path: vec![], 122 | skip_errors: false, 123 | exclude_path: vec![], 124 | }; 125 | let result = TMBliss::run(command); 126 | result.unwrap(); 127 | 128 | assert!( 129 | TimeMachine::is_excluded_deep(hmap.get("devfile").unwrap()).unwrap(), 130 | "Devfile is not excluded" 131 | ); 132 | assert!( 133 | !TimeMachine::is_excluded_deep(hmap.get("sub").unwrap()).unwrap(), 134 | "Sub directory is excluded, but it should not be" 135 | ); 136 | assert!( 137 | !TimeMachine::is_excluded_deep(hmap.get("sub/devfile").unwrap()).unwrap(), 138 | "Sub/devfile is excluded, but it should not be" 139 | ); 140 | } 141 | 142 | #[test] 143 | fn test_run() { 144 | let filetree = FileTree::new_test_repo(); 145 | 146 | let fmap = filetree.create(); 147 | let workspace = fmap.get("__workspace").unwrap(); 148 | 149 | let excluded_path = fmap.get("excluded_path").unwrap(); 150 | let not_excluded_glob = fmap.get(".excluded_glob").unwrap(); 151 | let not_excluded_dir = fmap.get("not_excluded_path").unwrap(); 152 | 153 | let command = Command::Run { 154 | path: vec![workspace.to_string_lossy().into_owned()], 155 | dry_run: false, 156 | allowlist_glob: vec![ 157 | "**/.excluded_glob".to_string(), 158 | ".excluded_glob.*".to_string(), 159 | ], 160 | allowlist_path: vec![not_excluded_dir.to_string_lossy().into_owned()], 161 | skip_glob: vec![], 162 | skip_path: vec![], 163 | skip_errors: false, 164 | exclude_path: vec![], 165 | }; 166 | let result = TMBliss::run(command); 167 | 168 | result.unwrap(); 169 | assert!(TimeMachine::is_excluded(excluded_path).unwrap()); 170 | assert!(!TimeMachine::is_excluded(not_excluded_glob).unwrap()); 171 | assert!(!TimeMachine::is_excluded(not_excluded_dir).unwrap()); 172 | } 173 | 174 | #[test] 175 | fn test_exclude_paths() { 176 | let filetree = FileTree::new_test_repo(); 177 | 178 | let fmap = filetree.create(); 179 | 180 | let file = fmap.get("path_that_should_be_excluded.txt").unwrap(); 181 | 182 | let command = Command::Run { 183 | path: vec![], 184 | dry_run: false, 185 | allowlist_glob: vec![], 186 | allowlist_path: vec![], 187 | skip_glob: vec![], 188 | skip_path: vec![], 189 | skip_errors: true, 190 | exclude_path: vec![file.to_string_lossy().into_owned()], 191 | }; 192 | let result = TMBliss::run(command); 193 | 194 | result.unwrap(); 195 | assert!(TimeMachine::is_excluded(file).unwrap()); 196 | } 197 | 198 | #[test] 199 | fn test_skip_errors() { 200 | let cwd = current_dir().unwrap(); 201 | 202 | let dir = cwd.join("test_assets"); 203 | 204 | let root_file = dir.join("root_file.txt"); 205 | 206 | { 207 | let command = Command::Run { 208 | path: vec![dir.to_string_lossy().into_owned()], 209 | dry_run: false, 210 | allowlist_glob: vec!["**/.DS_Store".to_string()], 211 | allowlist_path: vec![], 212 | skip_glob: vec![], 213 | skip_path: vec![], 214 | skip_errors: true, 215 | exclude_path: vec![root_file.to_string_lossy().into_owned()], 216 | }; 217 | let result = TMBliss::run(command); 218 | 219 | result.unwrap(); 220 | } 221 | 222 | { 223 | let command = Command::Run { 224 | path: vec![dir.to_string_lossy().into_owned()], 225 | dry_run: false, 226 | allowlist_glob: vec!["**/.DS_Store".to_string()], 227 | allowlist_path: vec![], 228 | skip_glob: vec![], 229 | skip_path: vec![], 230 | skip_errors: false, 231 | exclude_path: vec![root_file.to_string_lossy().into_owned()], 232 | }; 233 | let result = TMBliss::run(command); 234 | 235 | assert_eq!(result.unwrap_err().to_string(), "File inaccessible"); 236 | } 237 | } 238 | 239 | #[test] 240 | fn test_reset() { 241 | let filetree = FileTree::new_test_repo(); 242 | 243 | let fmap = filetree.create(); 244 | 245 | let dir = fmap.get("__workspace").unwrap(); 246 | 247 | let excluded_path = dir.join("excluded_path"); 248 | let not_excluded_glob = dir.join(".excluded_glob"); 249 | let not_excluded_path = dir.join("not_excluded_path"); 250 | 251 | TMBliss::run(Command::Run { 252 | path: vec![dir.to_string_lossy().into_owned()], 253 | dry_run: false, 254 | allowlist_glob: vec![], 255 | allowlist_path: vec![], 256 | skip_glob: vec![], 257 | skip_path: vec![], 258 | skip_errors: true, 259 | exclude_path: vec![], 260 | }) 261 | .unwrap(); 262 | 263 | assert!(TimeMachine::is_excluded(&excluded_path).unwrap()); 264 | assert!(TimeMachine::is_excluded(¬_excluded_glob).unwrap()); 265 | assert!(TimeMachine::is_excluded(¬_excluded_path).unwrap()); 266 | 267 | TMBliss::run(Command::Reset { 268 | path: dir.to_string_lossy().into_owned(), 269 | dry_run: false, 270 | allowlist_glob: vec!["**/.excluded_glob".to_string()], 271 | allowlist_path: vec![not_excluded_path.to_string_lossy().into_owned()], 272 | }) 273 | .unwrap(); 274 | 275 | assert!(!TimeMachine::is_excluded(&excluded_path).unwrap()); 276 | assert!(TimeMachine::is_excluded(¬_excluded_glob).unwrap()); 277 | assert!(TimeMachine::is_excluded(¬_excluded_path).unwrap()); 278 | } 279 | -------------------------------------------------------------------------------- /src/filetree.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | env, 4 | fs::{self}, 5 | io::Write, 6 | os::unix::fs::PermissionsExt, 7 | path::{self, PathBuf}, 8 | process::Command, 9 | }; 10 | 11 | use anyhow::Context; 12 | use uuid::Uuid; 13 | 14 | use crate::TimeMachine; 15 | 16 | const TMBLISS_FILE: &str = ".tmbliss"; 17 | 18 | pub enum FileTreeItem { 19 | Directory { 20 | key: String, 21 | name: String, 22 | is_excluded: bool, 23 | }, 24 | File { 25 | key: String, 26 | name: String, 27 | is_excluded: bool, 28 | }, 29 | Gitignore { 30 | key: String, 31 | path: String, 32 | patterns: Vec, 33 | }, 34 | TmBliss { 35 | key: String, 36 | path: String, 37 | patterns: Vec, 38 | }, 39 | } 40 | 41 | pub struct FileTree { 42 | path: PathBuf, 43 | items: Vec, 44 | } 45 | 46 | impl Drop for FileTree { 47 | fn drop(&mut self) { 48 | if self.path.exists() { 49 | fs::remove_dir_all(&self.path).unwrap(); 50 | } 51 | } 52 | } 53 | 54 | impl FileTree { 55 | pub fn new(items: Vec) -> Self { 56 | Self { 57 | path: env::temp_dir() 58 | .canonicalize() 59 | .unwrap() 60 | .join(format!("tmbliss_test_workspace_{}", Uuid::new_v4())) 61 | .to_path_buf(), 62 | items, 63 | } 64 | } 65 | 66 | pub fn new_test_repo() -> Self { 67 | FileTree::new(vec![ 68 | FileTreeItem::File { 69 | key: ".excluded_glob".to_string(), 70 | name: ".excluded_glob".to_string(), 71 | is_excluded: false, 72 | }, 73 | FileTreeItem::File { 74 | key: "path_that_should_be_excluded.txt".to_string(), 75 | name: "path_that_should_be_excluded.txt".to_string(), 76 | is_excluded: false, 77 | }, 78 | FileTreeItem::Directory { 79 | key: "not_excluded_path".to_string(), 80 | name: "not_excluded_path".to_string(), 81 | is_excluded: false, 82 | }, 83 | FileTreeItem::File { 84 | key: "not_excluded_path/file.txt".to_string(), 85 | name: "not_excluded_path/file.txt".to_string(), 86 | is_excluded: false, 87 | }, 88 | FileTreeItem::Directory { 89 | key: "excluded_path".to_string(), 90 | name: "excluded_path".to_string(), 91 | is_excluded: false, 92 | }, 93 | FileTreeItem::File { 94 | key: "excluded_path/file.txt".to_string(), 95 | name: "excluded_path/file.txt".to_string(), 96 | is_excluded: false, 97 | }, 98 | FileTreeItem::File { 99 | key: "nested_dir/excluded_file.txt".to_string(), 100 | name: "nested_dir/excluded_file.txt".to_string(), 101 | is_excluded: false, 102 | }, 103 | FileTreeItem::File { 104 | key: "nested_dir/included_file.txt".to_string(), 105 | name: "nested_dir/included_file.txt".to_string(), 106 | is_excluded: false, 107 | }, 108 | FileTreeItem::File { 109 | key: "nested_dir_with_single_file/excluded_file.txt".to_string(), 110 | name: "nested_dir_with_single_file/excluded_file.txt".to_string(), 111 | is_excluded: false, 112 | }, 113 | FileTreeItem::Gitignore { 114 | key: "gitignore".to_string(), 115 | path: "".to_string(), 116 | patterns: vec![ 117 | "/excluded_path".to_string(), 118 | "/not_excluded_path".to_string(), 119 | ".excluded_glob".to_string(), 120 | "/nested_dir/excluded_file.txt".to_string(), 121 | "/nested_dir_with_single_file/excluded_file.txt".to_string(), 122 | ], 123 | }, 124 | FileTreeItem::Directory { 125 | key: "directory_with_subgitignore".to_string(), 126 | name: "directory_with_subgitignore".to_string(), 127 | is_excluded: false, 128 | }, 129 | FileTreeItem::File { 130 | key: "directory_with_subgitignore/included_file.txt".to_string(), 131 | name: "directory_with_subgitignore/included_file.txt".to_string(), 132 | is_excluded: false, 133 | }, 134 | FileTreeItem::File { 135 | key: "directory_with_subgitignore/subignore.txt".to_string(), 136 | name: "directory_with_subgitignore/subignore.txt".to_string(), 137 | is_excluded: false, 138 | }, 139 | FileTreeItem::Gitignore { 140 | key: "sub_gitignore".to_string(), 141 | path: "directory_with_subgitignore".to_string(), 142 | patterns: vec!["subignore.txt".to_string()], 143 | }, 144 | ]) 145 | } 146 | 147 | pub fn create(&self) -> HashMap { 148 | if self.path.exists() { 149 | fs::remove_dir_all(&self.path).unwrap(); 150 | } 151 | 152 | fs::create_dir_all(&self.path).unwrap(); 153 | 154 | Command::new("/usr/bin/git") 155 | .arg("init") 156 | .current_dir(&self.path) 157 | .output() 158 | .expect("Failed to initialize git repository"); 159 | 160 | let mut hashmap: HashMap = HashMap::new(); 161 | 162 | hashmap.insert("__workspace".to_string(), self.path.clone()); 163 | 164 | for item in &self.items { 165 | let item_path = match item { 166 | FileTreeItem::Directory { name, .. } => self.path.join(name), 167 | FileTreeItem::File { name, .. } => self.path.join(name), 168 | FileTreeItem::Gitignore { path, .. } => self.path.join(path).join(".gitignore"), 169 | FileTreeItem::TmBliss { path, .. } => self.path.join(path).join(TMBLISS_FILE), 170 | }; 171 | let item_path = path::absolute(self.path.join(item_path)).unwrap(); 172 | let item_key = match item { 173 | FileTreeItem::Directory { key, .. } => key, 174 | FileTreeItem::File { key, .. } => key, 175 | FileTreeItem::Gitignore { key, .. } => key, 176 | FileTreeItem::TmBliss { key, .. } => key, 177 | }; 178 | 179 | hashmap.insert(item_key.clone(), item_path.clone()); 180 | 181 | let item_dir = item_path 182 | .parent() 183 | .with_context(|| format!("Failed to get parent directory for {:?}", item_path)) 184 | .unwrap(); 185 | 186 | fs::create_dir_all(item_dir) 187 | .with_context(|| format!("Failed to create directory {:?}", item_dir)) 188 | .unwrap(); 189 | 190 | match item { 191 | FileTreeItem::Directory { is_excluded, .. } => { 192 | fs::create_dir_all(&item_path).unwrap(); 193 | 194 | if *is_excluded { 195 | TimeMachine::add_exclusion(&item_path) 196 | .with_context(|| format!("Failed to add exclusion for {:?}", item_path)) 197 | .unwrap() 198 | } 199 | } 200 | FileTreeItem::File { is_excluded, .. } => { 201 | let file = fs::File::create(&item_path).unwrap(); 202 | let mut perms = file.metadata().unwrap().permissions(); 203 | perms.set_mode(0o777); 204 | fs::set_permissions(&item_path, perms).unwrap(); 205 | 206 | if *is_excluded { 207 | TimeMachine::add_exclusion(&item_path) 208 | .with_context(|| format!("Failed to add exclusion for {:?}", item_path)) 209 | .unwrap() 210 | } 211 | } 212 | FileTreeItem::Gitignore { patterns, .. } => { 213 | let mut gitignore = fs::File::create(&item_path).unwrap(); 214 | let mut perms = gitignore.metadata().unwrap().permissions(); 215 | perms.set_mode(0o777); 216 | fs::set_permissions(&item_path, perms).unwrap(); 217 | 218 | let content = patterns.join("\n"); 219 | gitignore.write_all(content.as_bytes()).unwrap(); 220 | gitignore.write_all(b"\n").unwrap(); 221 | } 222 | FileTreeItem::TmBliss { patterns, .. } => { 223 | let tmbliss_path = self.path.join(TMBLISS_FILE); 224 | let mut tmbliss = fs::File::create(&tmbliss_path).unwrap(); 225 | let mut perms = tmbliss.metadata().unwrap().permissions(); 226 | perms.set_mode(0o777); 227 | fs::set_permissions(&tmbliss_path, perms).unwrap(); 228 | 229 | let content = patterns.join("\n"); 230 | tmbliss.write_all(content.as_bytes()).unwrap(); 231 | tmbliss.write_all(b"\n").unwrap(); 232 | } 233 | } 234 | } 235 | 236 | hashmap 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsStr; 2 | use std::fs::File; 3 | use std::io::{BufRead, BufReader}; 4 | 5 | mod args; 6 | mod conf; 7 | mod constants; 8 | mod directory_iterator; 9 | mod git; 10 | mod logger; 11 | mod recursive_directory_iterator; 12 | mod time_machine; 13 | 14 | #[cfg(test)] 15 | pub mod filetree; 16 | #[cfg(test)] 17 | pub mod test_utils; 18 | 19 | use std::collections::HashSet; 20 | use std::path::Path; 21 | use std::rc::Rc; 22 | use std::{cell::RefCell, path::PathBuf}; 23 | 24 | use anyhow::{anyhow, Context, Result}; 25 | use glob_match::glob_match; 26 | use recursive_directory_iterator::RecursiveDirectoryIterator; 27 | 28 | pub use crate::args::{Args, Command}; 29 | use crate::conf::Conf; 30 | use crate::constants::TMBLISS_FILE; 31 | use crate::directory_iterator::DirectoryIterator; 32 | use crate::git::Git; 33 | use crate::logger::Logger; 34 | pub use crate::time_machine::{TimeMachine, TimeMachineError}; 35 | 36 | pub struct TMBliss {} 37 | 38 | impl TMBliss { 39 | pub fn run(command: Command) -> Result<()> { 40 | match command { 41 | Command::Run { 42 | path, 43 | dry_run, 44 | allowlist_glob, 45 | allowlist_path, 46 | skip_glob, 47 | skip_path, 48 | skip_errors, 49 | exclude_path, 50 | } => { 51 | let logger = Logger { filter: None }; 52 | 53 | Self::mark_files( 54 | Conf { 55 | paths: path, 56 | dry_run, 57 | allowlist_glob, 58 | allowlist_path, 59 | skip_glob, 60 | skip_path, 61 | skip_errors, 62 | exclude_paths: exclude_path, 63 | }, 64 | &logger, 65 | ) 66 | } 67 | Command::List { 68 | path, 69 | allowlist_glob, 70 | allowlist_path, 71 | skip_glob, 72 | skip_path, 73 | skip_errors, 74 | exclude_path, 75 | } => { 76 | let logger = Logger { filter: None }; 77 | 78 | Self::mark_files( 79 | Conf { 80 | paths: path, 81 | dry_run: true, 82 | allowlist_glob, 83 | allowlist_path, 84 | skip_glob, 85 | skip_path, 86 | skip_errors, 87 | exclude_paths: exclude_path, 88 | }, 89 | &logger, 90 | ) 91 | } 92 | Command::Conf { path, dry_run } => { 93 | let conf = Conf::parse(&path); 94 | match conf { 95 | Ok(mut conf) => { 96 | let logger = Logger { filter: None }; 97 | 98 | if let Some(dry_run) = dry_run { 99 | conf.dry_run = dry_run; 100 | } 101 | Self::mark_files(conf, &logger) 102 | } 103 | Err(e) => Err(e), 104 | } 105 | } 106 | Command::Service { path, dry_run } => { 107 | let conf = Conf::parse(&path); 108 | match conf { 109 | Ok(mut conf) => { 110 | let filter = |label: &str, _message: &str| { 111 | if label == "excluded" { 112 | return true; 113 | } 114 | false 115 | }; 116 | let logger = Logger { 117 | filter: Some(&filter), 118 | }; 119 | if let Some(dry_run) = dry_run { 120 | conf.dry_run = dry_run; 121 | } 122 | logger.log("started", &chrono::Local::now().to_string()); 123 | logger.log("dry run", &conf.dry_run.to_string()); 124 | Self::mark_files(conf, &logger)?; 125 | logger.log("ended", &chrono::Local::now().to_string()); 126 | Ok(()) 127 | } 128 | Err(e) => Err(e), 129 | } 130 | } 131 | Command::Reset { 132 | path, 133 | dry_run, 134 | allowlist_glob, 135 | allowlist_path, 136 | } => Self::reset_files( 137 | Path::new(&path), 138 | dry_run, 139 | allowlist_glob, 140 | allowlist_path, 141 | &Logger { filter: None }, 142 | ), 143 | Command::ShowExcluded { 144 | path, 145 | allowlist_glob, 146 | allowlist_path, 147 | } => Self::reset_files( 148 | Path::new(&path), 149 | true, 150 | allowlist_glob, 151 | allowlist_path, 152 | &Logger { filter: None }, 153 | ), 154 | Command::MarkdownHelp => { 155 | clap_markdown::print_help_markdown::(); 156 | Ok(()) 157 | } 158 | } 159 | } 160 | 161 | fn mark_files(conf: Conf, logger: &Logger) -> Result<()> { 162 | let processed: Rc>> = Rc::new(RefCell::new(HashSet::new())); 163 | 164 | for item in conf.exclude_paths.clone() { 165 | Self::process(Path::new(&item), &conf, processed.clone(), logger)?; 166 | } 167 | 168 | for path in &conf.paths { 169 | Self::process_directory(Path::new(path), &conf, processed.clone(), logger)?; 170 | } 171 | 172 | Ok(()) 173 | } 174 | 175 | fn reset_files( 176 | path: &Path, 177 | dry_run: bool, 178 | allowlist_glob: Vec, 179 | allowlist_path: Vec, 180 | logger: &Logger, 181 | ) -> Result<()> { 182 | let iterator = RecursiveDirectoryIterator { 183 | path, 184 | op: &|path| { 185 | for exclusion in &allowlist_path { 186 | if Self::is_inside(Path::new(exclusion), path) { 187 | return Ok(true); 188 | } 189 | } 190 | for exclusion in &allowlist_glob { 191 | if glob_match(exclusion, &path.to_string_lossy()) { 192 | return Ok(true); 193 | } 194 | } 195 | if TimeMachine::is_excluded(path)? { 196 | logger.log("excluded", &path.to_string_lossy()); 197 | if !dry_run { 198 | TimeMachine::remove_exclusion(path)? 199 | } 200 | } 201 | Ok(true) 202 | }, 203 | }; 204 | 205 | iterator.iterate() 206 | } 207 | 208 | fn process( 209 | item: &Path, 210 | conf: &Conf, 211 | processed: Rc>>, 212 | logger: &Logger, 213 | ) -> Result<()> { 214 | let item = item 215 | .canonicalize() 216 | .with_context(|| format!("Can't canonicalize path {}", item.display()))?; 217 | let item = &item; 218 | if processed.borrow().contains(item) { 219 | return Ok(()); 220 | } 221 | 222 | processed.borrow_mut().insert(item.to_owned()); 223 | 224 | let check_result = TimeMachine::is_excluded(item); 225 | match check_result { 226 | Ok(is_excluded) => { 227 | if is_excluded { 228 | logger.log("excluded", &item.to_string_lossy()); 229 | return Ok(()); 230 | } else { 231 | logger.log("new", &item.to_string_lossy()); 232 | } 233 | } 234 | Err(e) => { 235 | if conf.skip_errors { 236 | logger.log( 237 | "error_checking", 238 | &[item.to_string_lossy().as_ref(), &e.to_string()].join(", "), 239 | ); 240 | return Ok(()); 241 | } else { 242 | return Err(e.into()); 243 | } 244 | } 245 | } 246 | 247 | if !conf.dry_run { 248 | let result = TimeMachine::add_exclusion(item); 249 | match result { 250 | Ok(_) => {} 251 | Err(e) => { 252 | if conf.skip_errors { 253 | logger.log( 254 | "error_excluding", 255 | &[item.to_string_lossy().as_ref(), &e.to_string()].join(", "), 256 | ); 257 | } else { 258 | return Err(e.into()); 259 | } 260 | } 261 | } 262 | } 263 | 264 | Ok(()) 265 | } 266 | 267 | fn process_directory( 268 | path: &Path, 269 | conf: &Conf, 270 | processed: Rc>>, 271 | logger: &Logger, 272 | ) -> Result<()> { 273 | let path = Path::new(path) 274 | .canonicalize() 275 | .with_context(|| format!("Can't canonicalize path {}", path.display()))?; 276 | let path = &path; 277 | // Gather .tmbliss globs for this directory 278 | let mut newconf = conf.clone(); 279 | let mut effective_skip_glob = conf.skip_glob.clone(); 280 | let tmbliss_globs = Self::read_tmbliss_globs(path); 281 | let tmbliss_globs = tmbliss_globs 282 | .iter() 283 | .map(|s| -> Result { 284 | let stripped = if s.starts_with("/") { 285 | s.strip_prefix("/") 286 | .ok_or_else(|| anyhow!("Failed to strip prefix from {}", s))? 287 | } else { 288 | s.as_str() 289 | }; 290 | Ok(path.join(stripped).to_string_lossy().to_string()) 291 | }) 292 | .collect::>>()?; 293 | effective_skip_glob.extend(tmbliss_globs); 294 | newconf.skip_glob = effective_skip_glob.clone(); 295 | 296 | // Excluder closure that uses effective_skip_glob 297 | let excluder = |item: &Path| -> bool { 298 | if item.is_file() && item.file_name() == Some(OsStr::new(TMBLISS_FILE)) { 299 | return true; 300 | } 301 | if item.is_file() && item.file_name() == Some(OsStr::new(".gitignore")) { 302 | return true; 303 | } 304 | if processed.borrow().contains(item) { 305 | return true; 306 | } 307 | if Git::is_git(item) { 308 | processed.borrow_mut().insert(item.to_path_buf()); 309 | return true; 310 | } 311 | for exclusion in &effective_skip_glob { 312 | if glob_match(exclusion, &item.to_string_lossy()) { 313 | processed.borrow_mut().insert(item.to_path_buf()); 314 | return true; 315 | } 316 | } 317 | for exclusion in &conf.skip_path { 318 | if Self::is_inside(Path::new(exclusion), item) { 319 | processed.borrow_mut().insert(item.to_path_buf()); 320 | return true; 321 | } 322 | } 323 | false 324 | }; 325 | 326 | if excluder(path) { 327 | return Ok(()); 328 | } 329 | 330 | if TimeMachine::is_excluded(path)? { 331 | Self::process(path, &newconf, processed, logger) 332 | .with_context(|| format!("Can't process path {}", path.display()))?; 333 | return Ok(()); 334 | } 335 | 336 | let excludes = Self::get_git_excludes(path, &newconf); 337 | 338 | let parents = |item: &Path| -> Vec { 339 | let mut out: Vec = vec![]; 340 | let mut p = item.parent(); 341 | while let Some(par) = p { 342 | if par != path { 343 | out.push(par.to_path_buf()); 344 | p = par.parent(); 345 | } else { 346 | break; 347 | } 348 | } 349 | out.reverse(); 350 | out 351 | }; 352 | 353 | for item in excludes.clone() { 354 | let excluded = excluder(&item) || parents(&item).iter().any(|p| excluder(p)); 355 | if excluded { 356 | continue; 357 | }; 358 | Self::process(Path::new(&item), &newconf, processed.clone(), logger).with_context( 359 | || { 360 | format!( 361 | "Can't process paths {}", 362 | excludes 363 | .iter() 364 | .map(|p| p.to_string_lossy()) 365 | .collect::>() 366 | .join(", ") 367 | ) 368 | }, 369 | )?; 370 | } 371 | 372 | let directory_iterator = DirectoryIterator { 373 | path, 374 | exclude: Some(&excluder), 375 | }; 376 | let directories = directory_iterator 377 | .list() 378 | .with_context(|| format!("Can't list directory {}", path.display()))?; 379 | 380 | for path in directories { 381 | // Recurse, passing down the new effective_skip_glob 382 | Self::process_directory(&path, &newconf, processed.clone(), logger) 383 | .with_context(|| format!("Can't process directory {}", path.display()))?; 384 | } 385 | 386 | Ok(()) 387 | } 388 | 389 | fn get_git_excludes(path: &Path, conf: &Conf) -> Vec { 390 | let git = Git { 391 | path: path.to_path_buf(), 392 | }; 393 | git.get_ignores_list() 394 | .unwrap_or_default() 395 | .into_iter() 396 | .filter(|item| { 397 | for exclusion in &conf.skip_path { 398 | if Self::is_inside(Path::new(exclusion), item) { 399 | return false; 400 | } 401 | } 402 | for exclusion in &conf.skip_glob { 403 | if glob_match(exclusion, &item.to_string_lossy()) { 404 | return false; 405 | } 406 | } 407 | for exclusion in &conf.allowlist_path { 408 | if Self::is_inside(Path::new(exclusion), item) { 409 | return false; 410 | } 411 | } 412 | for exclusion in &conf.allowlist_glob { 413 | if glob_match(exclusion, &item.to_string_lossy()) { 414 | return false; 415 | } 416 | } 417 | true 418 | }) 419 | .collect() 420 | } 421 | 422 | fn is_inside(root: &Path, child: &Path) -> bool { 423 | let root = root 424 | .canonicalize() 425 | .unwrap_or_else(|_| Path::new(root).to_path_buf()); 426 | let child = child 427 | .canonicalize() 428 | .unwrap_or_else(|_| Path::new(child).to_path_buf()); 429 | 430 | root.eq(&child) || (child.starts_with(&root) && !root.starts_with(&child)) 431 | } 432 | 433 | // Reads .tmbliss file in the given directory and returns a Vec of globs (ignores comments and empty lines) 434 | fn read_tmbliss_globs(dir: &Path) -> Vec { 435 | let tmbliss_path = dir.join(TMBLISS_FILE); 436 | let file = File::open(&tmbliss_path); 437 | if let Ok(file) = file { 438 | let reader = BufReader::new(file); 439 | reader 440 | .lines() 441 | .filter_map(|line| { 442 | let line = line.ok()?.trim().to_string(); 443 | if line.is_empty() || line.starts_with('#') { 444 | None 445 | } else { 446 | Some(line) 447 | } 448 | }) 449 | .collect() 450 | } else { 451 | Vec::new() 452 | } 453 | } 454 | } 455 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "android_system_properties" 16 | version = "0.1.5" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 19 | dependencies = [ 20 | "libc", 21 | ] 22 | 23 | [[package]] 24 | name = "anstream" 25 | version = "0.6.20" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" 28 | dependencies = [ 29 | "anstyle", 30 | "anstyle-parse", 31 | "anstyle-query", 32 | "anstyle-wincon", 33 | "colorchoice", 34 | "is_terminal_polyfill", 35 | "utf8parse", 36 | ] 37 | 38 | [[package]] 39 | name = "anstyle" 40 | version = "1.0.11" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" 43 | 44 | [[package]] 45 | name = "anstyle-parse" 46 | version = "0.2.7" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 49 | dependencies = [ 50 | "utf8parse", 51 | ] 52 | 53 | [[package]] 54 | name = "anstyle-query" 55 | version = "1.1.4" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" 58 | dependencies = [ 59 | "windows-sys 0.60.2", 60 | ] 61 | 62 | [[package]] 63 | name = "anstyle-wincon" 64 | version = "3.0.10" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" 67 | dependencies = [ 68 | "anstyle", 69 | "once_cell_polyfill", 70 | "windows-sys 0.60.2", 71 | ] 72 | 73 | [[package]] 74 | name = "anyhow" 75 | version = "1.0.99" 76 | source = "registry+https://github.com/rust-lang/crates.io-index" 77 | checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" 78 | 79 | [[package]] 80 | name = "assert_matches" 81 | version = "1.5.0" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" 84 | 85 | [[package]] 86 | name = "autocfg" 87 | version = "1.5.0" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 90 | 91 | [[package]] 92 | name = "bitflags" 93 | version = "2.9.4" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" 96 | 97 | [[package]] 98 | name = "bstr" 99 | version = "1.12.0" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" 102 | dependencies = [ 103 | "memchr", 104 | "serde", 105 | ] 106 | 107 | [[package]] 108 | name = "bumpalo" 109 | version = "3.19.0" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 112 | 113 | [[package]] 114 | name = "cc" 115 | version = "1.2.37" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" 118 | dependencies = [ 119 | "find-msvc-tools", 120 | "shlex", 121 | ] 122 | 123 | [[package]] 124 | name = "cfg-if" 125 | version = "1.0.3" 126 | source = "registry+https://github.com/rust-lang/crates.io-index" 127 | checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" 128 | 129 | [[package]] 130 | name = "chrono" 131 | version = "0.4.42" 132 | source = "registry+https://github.com/rust-lang/crates.io-index" 133 | checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" 134 | dependencies = [ 135 | "iana-time-zone", 136 | "js-sys", 137 | "num-traits", 138 | "wasm-bindgen", 139 | "windows-link 0.2.0", 140 | ] 141 | 142 | [[package]] 143 | name = "clap" 144 | version = "4.5.47" 145 | source = "registry+https://github.com/rust-lang/crates.io-index" 146 | checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" 147 | dependencies = [ 148 | "clap_builder", 149 | "clap_derive", 150 | ] 151 | 152 | [[package]] 153 | name = "clap-markdown" 154 | version = "0.1.5" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "d2a2617956a06d4885b490697b5307ebb09fec10b088afc18c81762d848c2339" 157 | dependencies = [ 158 | "clap", 159 | ] 160 | 161 | [[package]] 162 | name = "clap_builder" 163 | version = "4.5.47" 164 | source = "registry+https://github.com/rust-lang/crates.io-index" 165 | checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" 166 | dependencies = [ 167 | "anstream", 168 | "anstyle", 169 | "clap_lex", 170 | "strsim", 171 | ] 172 | 173 | [[package]] 174 | name = "clap_derive" 175 | version = "4.5.47" 176 | source = "registry+https://github.com/rust-lang/crates.io-index" 177 | checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" 178 | dependencies = [ 179 | "heck", 180 | "proc-macro2", 181 | "quote", 182 | "syn", 183 | ] 184 | 185 | [[package]] 186 | name = "clap_lex" 187 | version = "0.7.5" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" 190 | 191 | [[package]] 192 | name = "colorchoice" 193 | version = "1.0.4" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 196 | 197 | [[package]] 198 | name = "core-foundation-sys" 199 | version = "0.8.7" 200 | source = "registry+https://github.com/rust-lang/crates.io-index" 201 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 202 | 203 | [[package]] 204 | name = "crossbeam-deque" 205 | version = "0.8.6" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" 208 | dependencies = [ 209 | "crossbeam-epoch", 210 | "crossbeam-utils", 211 | ] 212 | 213 | [[package]] 214 | name = "crossbeam-epoch" 215 | version = "0.9.18" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 218 | dependencies = [ 219 | "crossbeam-utils", 220 | ] 221 | 222 | [[package]] 223 | name = "crossbeam-utils" 224 | version = "0.8.21" 225 | source = "registry+https://github.com/rust-lang/crates.io-index" 226 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 227 | 228 | [[package]] 229 | name = "errno" 230 | version = "0.3.14" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 233 | dependencies = [ 234 | "libc", 235 | "windows-sys 0.61.0", 236 | ] 237 | 238 | [[package]] 239 | name = "find-msvc-tools" 240 | version = "0.1.1" 241 | source = "registry+https://github.com/rust-lang/crates.io-index" 242 | checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" 243 | 244 | [[package]] 245 | name = "getrandom" 246 | version = "0.3.3" 247 | source = "registry+https://github.com/rust-lang/crates.io-index" 248 | checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" 249 | dependencies = [ 250 | "cfg-if", 251 | "libc", 252 | "r-efi", 253 | "wasi", 254 | ] 255 | 256 | [[package]] 257 | name = "glob-match" 258 | version = "0.2.1" 259 | source = "registry+https://github.com/rust-lang/crates.io-index" 260 | checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" 261 | 262 | [[package]] 263 | name = "globset" 264 | version = "0.4.16" 265 | source = "registry+https://github.com/rust-lang/crates.io-index" 266 | checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" 267 | dependencies = [ 268 | "aho-corasick", 269 | "bstr", 270 | "log", 271 | "regex-automata", 272 | "regex-syntax", 273 | ] 274 | 275 | [[package]] 276 | name = "heck" 277 | version = "0.5.0" 278 | source = "registry+https://github.com/rust-lang/crates.io-index" 279 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 280 | 281 | [[package]] 282 | name = "iana-time-zone" 283 | version = "0.1.64" 284 | source = "registry+https://github.com/rust-lang/crates.io-index" 285 | checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" 286 | dependencies = [ 287 | "android_system_properties", 288 | "core-foundation-sys", 289 | "iana-time-zone-haiku", 290 | "js-sys", 291 | "log", 292 | "wasm-bindgen", 293 | "windows-core", 294 | ] 295 | 296 | [[package]] 297 | name = "iana-time-zone-haiku" 298 | version = "0.1.2" 299 | source = "registry+https://github.com/rust-lang/crates.io-index" 300 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 301 | dependencies = [ 302 | "cc", 303 | ] 304 | 305 | [[package]] 306 | name = "ignore" 307 | version = "0.4.23" 308 | source = "registry+https://github.com/rust-lang/crates.io-index" 309 | checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" 310 | dependencies = [ 311 | "crossbeam-deque", 312 | "globset", 313 | "log", 314 | "memchr", 315 | "regex-automata", 316 | "same-file", 317 | "walkdir", 318 | "winapi-util", 319 | ] 320 | 321 | [[package]] 322 | name = "is_terminal_polyfill" 323 | version = "1.70.1" 324 | source = "registry+https://github.com/rust-lang/crates.io-index" 325 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 326 | 327 | [[package]] 328 | name = "itoa" 329 | version = "1.0.15" 330 | source = "registry+https://github.com/rust-lang/crates.io-index" 331 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 332 | 333 | [[package]] 334 | name = "js-sys" 335 | version = "0.3.80" 336 | source = "registry+https://github.com/rust-lang/crates.io-index" 337 | checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" 338 | dependencies = [ 339 | "once_cell", 340 | "wasm-bindgen", 341 | ] 342 | 343 | [[package]] 344 | name = "libc" 345 | version = "0.2.175" 346 | source = "registry+https://github.com/rust-lang/crates.io-index" 347 | checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" 348 | 349 | [[package]] 350 | name = "linux-raw-sys" 351 | version = "0.11.0" 352 | source = "registry+https://github.com/rust-lang/crates.io-index" 353 | checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 354 | 355 | [[package]] 356 | name = "log" 357 | version = "0.4.28" 358 | source = "registry+https://github.com/rust-lang/crates.io-index" 359 | checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 360 | 361 | [[package]] 362 | name = "memchr" 363 | version = "2.7.5" 364 | source = "registry+https://github.com/rust-lang/crates.io-index" 365 | checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" 366 | 367 | [[package]] 368 | name = "num-traits" 369 | version = "0.2.19" 370 | source = "registry+https://github.com/rust-lang/crates.io-index" 371 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 372 | dependencies = [ 373 | "autocfg", 374 | ] 375 | 376 | [[package]] 377 | name = "once_cell" 378 | version = "1.21.3" 379 | source = "registry+https://github.com/rust-lang/crates.io-index" 380 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 381 | 382 | [[package]] 383 | name = "once_cell_polyfill" 384 | version = "1.70.1" 385 | source = "registry+https://github.com/rust-lang/crates.io-index" 386 | checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" 387 | 388 | [[package]] 389 | name = "ppv-lite86" 390 | version = "0.2.21" 391 | source = "registry+https://github.com/rust-lang/crates.io-index" 392 | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 393 | dependencies = [ 394 | "zerocopy", 395 | ] 396 | 397 | [[package]] 398 | name = "proc-macro2" 399 | version = "1.0.101" 400 | source = "registry+https://github.com/rust-lang/crates.io-index" 401 | checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" 402 | dependencies = [ 403 | "unicode-ident", 404 | ] 405 | 406 | [[package]] 407 | name = "quote" 408 | version = "1.0.40" 409 | source = "registry+https://github.com/rust-lang/crates.io-index" 410 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 411 | dependencies = [ 412 | "proc-macro2", 413 | ] 414 | 415 | [[package]] 416 | name = "r-efi" 417 | version = "5.3.0" 418 | source = "registry+https://github.com/rust-lang/crates.io-index" 419 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 420 | 421 | [[package]] 422 | name = "rand" 423 | version = "0.9.2" 424 | source = "registry+https://github.com/rust-lang/crates.io-index" 425 | checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 426 | dependencies = [ 427 | "rand_chacha", 428 | "rand_core", 429 | ] 430 | 431 | [[package]] 432 | name = "rand_chacha" 433 | version = "0.9.0" 434 | source = "registry+https://github.com/rust-lang/crates.io-index" 435 | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 436 | dependencies = [ 437 | "ppv-lite86", 438 | "rand_core", 439 | ] 440 | 441 | [[package]] 442 | name = "rand_core" 443 | version = "0.9.3" 444 | source = "registry+https://github.com/rust-lang/crates.io-index" 445 | checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 446 | dependencies = [ 447 | "getrandom", 448 | ] 449 | 450 | [[package]] 451 | name = "regex" 452 | version = "1.11.2" 453 | source = "registry+https://github.com/rust-lang/crates.io-index" 454 | checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" 455 | dependencies = [ 456 | "aho-corasick", 457 | "memchr", 458 | "regex-automata", 459 | "regex-syntax", 460 | ] 461 | 462 | [[package]] 463 | name = "regex-automata" 464 | version = "0.4.10" 465 | source = "registry+https://github.com/rust-lang/crates.io-index" 466 | checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" 467 | dependencies = [ 468 | "aho-corasick", 469 | "memchr", 470 | "regex-syntax", 471 | ] 472 | 473 | [[package]] 474 | name = "regex-syntax" 475 | version = "0.8.6" 476 | source = "registry+https://github.com/rust-lang/crates.io-index" 477 | checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" 478 | 479 | [[package]] 480 | name = "rustix" 481 | version = "1.1.2" 482 | source = "registry+https://github.com/rust-lang/crates.io-index" 483 | checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" 484 | dependencies = [ 485 | "bitflags", 486 | "errno", 487 | "libc", 488 | "linux-raw-sys", 489 | "windows-sys 0.61.0", 490 | ] 491 | 492 | [[package]] 493 | name = "rustversion" 494 | version = "1.0.22" 495 | source = "registry+https://github.com/rust-lang/crates.io-index" 496 | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 497 | 498 | [[package]] 499 | name = "ryu" 500 | version = "1.0.20" 501 | source = "registry+https://github.com/rust-lang/crates.io-index" 502 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 503 | 504 | [[package]] 505 | name = "same-file" 506 | version = "1.0.6" 507 | source = "registry+https://github.com/rust-lang/crates.io-index" 508 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 509 | dependencies = [ 510 | "winapi-util", 511 | ] 512 | 513 | [[package]] 514 | name = "serde" 515 | version = "1.0.225" 516 | source = "registry+https://github.com/rust-lang/crates.io-index" 517 | checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" 518 | dependencies = [ 519 | "serde_core", 520 | "serde_derive", 521 | ] 522 | 523 | [[package]] 524 | name = "serde_core" 525 | version = "1.0.225" 526 | source = "registry+https://github.com/rust-lang/crates.io-index" 527 | checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" 528 | dependencies = [ 529 | "serde_derive", 530 | ] 531 | 532 | [[package]] 533 | name = "serde_derive" 534 | version = "1.0.225" 535 | source = "registry+https://github.com/rust-lang/crates.io-index" 536 | checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" 537 | dependencies = [ 538 | "proc-macro2", 539 | "quote", 540 | "syn", 541 | ] 542 | 543 | [[package]] 544 | name = "serde_json" 545 | version = "1.0.145" 546 | source = "registry+https://github.com/rust-lang/crates.io-index" 547 | checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" 548 | dependencies = [ 549 | "itoa", 550 | "memchr", 551 | "ryu", 552 | "serde", 553 | "serde_core", 554 | ] 555 | 556 | [[package]] 557 | name = "shlex" 558 | version = "1.3.0" 559 | source = "registry+https://github.com/rust-lang/crates.io-index" 560 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 561 | 562 | [[package]] 563 | name = "strsim" 564 | version = "0.11.1" 565 | source = "registry+https://github.com/rust-lang/crates.io-index" 566 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 567 | 568 | [[package]] 569 | name = "syn" 570 | version = "2.0.106" 571 | source = "registry+https://github.com/rust-lang/crates.io-index" 572 | checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" 573 | dependencies = [ 574 | "proc-macro2", 575 | "quote", 576 | "unicode-ident", 577 | ] 578 | 579 | [[package]] 580 | name = "test-case" 581 | version = "3.3.1" 582 | source = "registry+https://github.com/rust-lang/crates.io-index" 583 | checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" 584 | dependencies = [ 585 | "test-case-macros", 586 | ] 587 | 588 | [[package]] 589 | name = "test-case-core" 590 | version = "3.3.1" 591 | source = "registry+https://github.com/rust-lang/crates.io-index" 592 | checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" 593 | dependencies = [ 594 | "cfg-if", 595 | "proc-macro2", 596 | "quote", 597 | "syn", 598 | ] 599 | 600 | [[package]] 601 | name = "test-case-macros" 602 | version = "3.3.1" 603 | source = "registry+https://github.com/rust-lang/crates.io-index" 604 | checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" 605 | dependencies = [ 606 | "proc-macro2", 607 | "quote", 608 | "syn", 609 | "test-case-core", 610 | ] 611 | 612 | [[package]] 613 | name = "tmbliss" 614 | version = "0.0.1-beta.16" 615 | dependencies = [ 616 | "anyhow", 617 | "assert_matches", 618 | "chrono", 619 | "clap", 620 | "clap-markdown", 621 | "glob-match", 622 | "ignore", 623 | "regex", 624 | "serde", 625 | "serde_json", 626 | "test-case", 627 | "uuid", 628 | "xattr", 629 | ] 630 | 631 | [[package]] 632 | name = "unicode-ident" 633 | version = "1.0.19" 634 | source = "registry+https://github.com/rust-lang/crates.io-index" 635 | checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" 636 | 637 | [[package]] 638 | name = "utf8parse" 639 | version = "0.2.2" 640 | source = "registry+https://github.com/rust-lang/crates.io-index" 641 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 642 | 643 | [[package]] 644 | name = "uuid" 645 | version = "1.18.1" 646 | source = "registry+https://github.com/rust-lang/crates.io-index" 647 | checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" 648 | dependencies = [ 649 | "getrandom", 650 | "js-sys", 651 | "rand", 652 | "wasm-bindgen", 653 | ] 654 | 655 | [[package]] 656 | name = "walkdir" 657 | version = "2.5.0" 658 | source = "registry+https://github.com/rust-lang/crates.io-index" 659 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 660 | dependencies = [ 661 | "same-file", 662 | "winapi-util", 663 | ] 664 | 665 | [[package]] 666 | name = "wasi" 667 | version = "0.14.7+wasi-0.2.4" 668 | source = "registry+https://github.com/rust-lang/crates.io-index" 669 | checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" 670 | dependencies = [ 671 | "wasip2", 672 | ] 673 | 674 | [[package]] 675 | name = "wasip2" 676 | version = "1.0.1+wasi-0.2.4" 677 | source = "registry+https://github.com/rust-lang/crates.io-index" 678 | checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 679 | dependencies = [ 680 | "wit-bindgen", 681 | ] 682 | 683 | [[package]] 684 | name = "wasm-bindgen" 685 | version = "0.2.103" 686 | source = "registry+https://github.com/rust-lang/crates.io-index" 687 | checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" 688 | dependencies = [ 689 | "cfg-if", 690 | "once_cell", 691 | "rustversion", 692 | "wasm-bindgen-macro", 693 | "wasm-bindgen-shared", 694 | ] 695 | 696 | [[package]] 697 | name = "wasm-bindgen-backend" 698 | version = "0.2.103" 699 | source = "registry+https://github.com/rust-lang/crates.io-index" 700 | checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" 701 | dependencies = [ 702 | "bumpalo", 703 | "log", 704 | "proc-macro2", 705 | "quote", 706 | "syn", 707 | "wasm-bindgen-shared", 708 | ] 709 | 710 | [[package]] 711 | name = "wasm-bindgen-macro" 712 | version = "0.2.103" 713 | source = "registry+https://github.com/rust-lang/crates.io-index" 714 | checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" 715 | dependencies = [ 716 | "quote", 717 | "wasm-bindgen-macro-support", 718 | ] 719 | 720 | [[package]] 721 | name = "wasm-bindgen-macro-support" 722 | version = "0.2.103" 723 | source = "registry+https://github.com/rust-lang/crates.io-index" 724 | checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" 725 | dependencies = [ 726 | "proc-macro2", 727 | "quote", 728 | "syn", 729 | "wasm-bindgen-backend", 730 | "wasm-bindgen-shared", 731 | ] 732 | 733 | [[package]] 734 | name = "wasm-bindgen-shared" 735 | version = "0.2.103" 736 | source = "registry+https://github.com/rust-lang/crates.io-index" 737 | checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" 738 | dependencies = [ 739 | "unicode-ident", 740 | ] 741 | 742 | [[package]] 743 | name = "winapi-util" 744 | version = "0.1.11" 745 | source = "registry+https://github.com/rust-lang/crates.io-index" 746 | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 747 | dependencies = [ 748 | "windows-sys 0.61.0", 749 | ] 750 | 751 | [[package]] 752 | name = "windows-core" 753 | version = "0.62.0" 754 | source = "registry+https://github.com/rust-lang/crates.io-index" 755 | checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" 756 | dependencies = [ 757 | "windows-implement", 758 | "windows-interface", 759 | "windows-link 0.2.0", 760 | "windows-result", 761 | "windows-strings", 762 | ] 763 | 764 | [[package]] 765 | name = "windows-implement" 766 | version = "0.60.0" 767 | source = "registry+https://github.com/rust-lang/crates.io-index" 768 | checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" 769 | dependencies = [ 770 | "proc-macro2", 771 | "quote", 772 | "syn", 773 | ] 774 | 775 | [[package]] 776 | name = "windows-interface" 777 | version = "0.59.1" 778 | source = "registry+https://github.com/rust-lang/crates.io-index" 779 | checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" 780 | dependencies = [ 781 | "proc-macro2", 782 | "quote", 783 | "syn", 784 | ] 785 | 786 | [[package]] 787 | name = "windows-link" 788 | version = "0.1.3" 789 | source = "registry+https://github.com/rust-lang/crates.io-index" 790 | checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" 791 | 792 | [[package]] 793 | name = "windows-link" 794 | version = "0.2.0" 795 | source = "registry+https://github.com/rust-lang/crates.io-index" 796 | checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" 797 | 798 | [[package]] 799 | name = "windows-result" 800 | version = "0.4.0" 801 | source = "registry+https://github.com/rust-lang/crates.io-index" 802 | checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" 803 | dependencies = [ 804 | "windows-link 0.2.0", 805 | ] 806 | 807 | [[package]] 808 | name = "windows-strings" 809 | version = "0.5.0" 810 | source = "registry+https://github.com/rust-lang/crates.io-index" 811 | checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" 812 | dependencies = [ 813 | "windows-link 0.2.0", 814 | ] 815 | 816 | [[package]] 817 | name = "windows-sys" 818 | version = "0.60.2" 819 | source = "registry+https://github.com/rust-lang/crates.io-index" 820 | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 821 | dependencies = [ 822 | "windows-targets", 823 | ] 824 | 825 | [[package]] 826 | name = "windows-sys" 827 | version = "0.61.0" 828 | source = "registry+https://github.com/rust-lang/crates.io-index" 829 | checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" 830 | dependencies = [ 831 | "windows-link 0.2.0", 832 | ] 833 | 834 | [[package]] 835 | name = "windows-targets" 836 | version = "0.53.3" 837 | source = "registry+https://github.com/rust-lang/crates.io-index" 838 | checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" 839 | dependencies = [ 840 | "windows-link 0.1.3", 841 | "windows_aarch64_gnullvm", 842 | "windows_aarch64_msvc", 843 | "windows_i686_gnu", 844 | "windows_i686_gnullvm", 845 | "windows_i686_msvc", 846 | "windows_x86_64_gnu", 847 | "windows_x86_64_gnullvm", 848 | "windows_x86_64_msvc", 849 | ] 850 | 851 | [[package]] 852 | name = "windows_aarch64_gnullvm" 853 | version = "0.53.0" 854 | source = "registry+https://github.com/rust-lang/crates.io-index" 855 | checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" 856 | 857 | [[package]] 858 | name = "windows_aarch64_msvc" 859 | version = "0.53.0" 860 | source = "registry+https://github.com/rust-lang/crates.io-index" 861 | checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" 862 | 863 | [[package]] 864 | name = "windows_i686_gnu" 865 | version = "0.53.0" 866 | source = "registry+https://github.com/rust-lang/crates.io-index" 867 | checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" 868 | 869 | [[package]] 870 | name = "windows_i686_gnullvm" 871 | version = "0.53.0" 872 | source = "registry+https://github.com/rust-lang/crates.io-index" 873 | checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" 874 | 875 | [[package]] 876 | name = "windows_i686_msvc" 877 | version = "0.53.0" 878 | source = "registry+https://github.com/rust-lang/crates.io-index" 879 | checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" 880 | 881 | [[package]] 882 | name = "windows_x86_64_gnu" 883 | version = "0.53.0" 884 | source = "registry+https://github.com/rust-lang/crates.io-index" 885 | checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" 886 | 887 | [[package]] 888 | name = "windows_x86_64_gnullvm" 889 | version = "0.53.0" 890 | source = "registry+https://github.com/rust-lang/crates.io-index" 891 | checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" 892 | 893 | [[package]] 894 | name = "windows_x86_64_msvc" 895 | version = "0.53.0" 896 | source = "registry+https://github.com/rust-lang/crates.io-index" 897 | checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" 898 | 899 | [[package]] 900 | name = "wit-bindgen" 901 | version = "0.46.0" 902 | source = "registry+https://github.com/rust-lang/crates.io-index" 903 | checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 904 | 905 | [[package]] 906 | name = "xattr" 907 | version = "1.5.1" 908 | source = "registry+https://github.com/rust-lang/crates.io-index" 909 | checksum = "af3a19837351dc82ba89f8a125e22a3c475f05aba604acc023d62b2739ae2909" 910 | dependencies = [ 911 | "libc", 912 | "rustix", 913 | ] 914 | 915 | [[package]] 916 | name = "zerocopy" 917 | version = "0.8.27" 918 | source = "registry+https://github.com/rust-lang/crates.io-index" 919 | checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" 920 | dependencies = [ 921 | "zerocopy-derive", 922 | ] 923 | 924 | [[package]] 925 | name = "zerocopy-derive" 926 | version = "0.8.27" 927 | source = "registry+https://github.com/rust-lang/crates.io-index" 928 | checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" 929 | dependencies = [ 930 | "proc-macro2", 931 | "quote", 932 | "syn", 933 | ] 934 | --------------------------------------------------------------------------------