diff --git a/README.md b/README.md index b73fc0d..881c806 100644 --- a/README.md +++ b/README.md @@ -46,16 +46,19 @@ The crates.io package is `rpass-cli`; the installed binary is `rpass`. - Gpg4win or GnuPG 2.x. - `rpass` detects common GnuPG install paths automatically. - You can also set `PASSWORD_STORE_GPG` to a specific `gpg.exe`. +- Git is optional and only required for `rpass git ...` workflows. ### macOS - GnuPG 2.x from a package manager or installer. - `gpg` should be available in `PATH`. +- Git is optional and only required for `rpass git ...` workflows. ### Linux - GnuPG 2.x from your distribution packages. - `gpg` should be available in `PATH`. +- Git is optional and only required for `rpass git ...` workflows. ## Store Directory @@ -67,12 +70,12 @@ The crates.io package is `rpass-cli`; the installed binary is `rpass`. ## Status -`rpass` can list, search, show, generate, insert, edit, remove, and move -password-store entries using external GnuPG. It also supports TOTP generation -from `otpauth://` lines. +`rpass` can list, search, show, generate, insert, edit, remove, move, and run +Git commands for password-store entries using external GnuPG. It also supports +TOTP generation from `otpauth://` lines. -Commands such as Git integration, clipboard support, and store initialization -are intentionally not implemented yet. +Commands such as clipboard support and store initialization are intentionally +not implemented yet. ## Commands @@ -86,6 +89,8 @@ rpass insert example/login # insert a password interactively rpass edit example/login # edit or create an entry rpass rm example/login # remove an entry rpass mv example/login archive/login # move or rename an entry +rpass git status # run git inside the store +rpass git init # initialize store Git history rpass otp example/login # generate an OTP code rpass doctor # check local setup ``` @@ -99,6 +104,12 @@ terminal. Use `--echo` to show input, `--multiline` to read the full entry until EOF, and `--force` to overwrite an existing entry. In multiline mode, the first line is the password and additional lines are metadata. +`rpass git ` passes arguments to Git using the password store as the +repository. `rpass git init` also stages the current store and creates the same +initial commit used by `pass`. When the store is a Git repository, write +commands automatically create matching commits. Use `rpass git --json ` +for structured stdout, stderr, and exit code output. + Most read commands support `--json` for integrations. Commands that decrypt entries also support `--passphrase-stdin` for non-interactive integrations: @@ -142,8 +153,9 @@ Supported behavior: Known differences from `pass`: - write support is limited to `generate`, `insert`, `edit`, `rm`, and `mv`; -- shell completion, clipboard, QR code, Git, and store - initialization are not implemented; +- Git integration is explicit through `rpass git `; +- shell completion, clipboard, QR code, and store initialization are not + implemented; - unsupported `pass` flags are rejected instead of ignored; - JSON output is an `rpass` integration contract, not part of the original `pass` CLI. diff --git a/TODO.md b/TODO.md index 77afd8c..914ca02 100644 --- a/TODO.md +++ b/TODO.md @@ -119,12 +119,13 @@ Android Password Store, and existing Git-based stores. Goal: provide explicit, predictable Git commands for password-store workflows. -- [ ] `rpass git status` -- [ ] `rpass git pull` -- [ ] `rpass git push` -- [ ] `rpass git log` -- [ ] Keep Git integration optional. -- [ ] Return structured errors for missing Git repositories. +- [x] `rpass git status` +- [x] `rpass git pull` +- [x] `rpass git push` +- [x] `rpass git log` +- [x] Auto-commit write commands when the store is a Git repository. +- [x] Keep Git integration optional. +- [x] Return structured errors for missing Git repositories. ## Phase 4: Initialization And Store Management diff --git a/src/cli.rs b/src/cli.rs index 45bc508..63357b0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -13,8 +13,8 @@ use crate::password_generator::{ max_passphrase_words, max_password_length, }; use crate::password_store::{ - DecryptedEntry, DoctorReport, EditEntry, GpgCommand, InsertEntry, ListEntries, MoveEntry, - OtpCode, PasswordStore, RemoveEntry, SearchEntries, ShowEntry, StoreDirectory, + DecryptedEntry, DoctorReport, EditEntry, GitCommand, GpgCommand, InsertEntry, ListEntries, + MoveEntry, OtpCode, PasswordStore, RemoveEntry, SearchEntries, ShowEntry, StoreDirectory, }; use tree_output::EntryTree; @@ -71,6 +71,9 @@ enum Command { #[command(name = "mv", about = "Move or rename a password store entry")] Move(MoveCommand), + #[command(about = "Run git inside the password store")] + Git(GitStoreCommand), + #[command(about = "Generate and insert a password store entry")] Generate(GenerateCommand), @@ -149,6 +152,15 @@ struct MoveCommand { json: bool, } +#[derive(Debug, Parser)] +struct GitStoreCommand { + #[arg(long)] + json: bool, + + #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, +} + #[derive(Debug, Parser)] struct GenerateCommand { entry: Option, @@ -299,6 +311,7 @@ pub fn run() -> Result<(), CliError> { Some(Command::Edit(command)) => edit_entry(command, store_directory), Some(Command::Remove(command)) => remove_entry(command, store_directory), Some(Command::Move(command)) => move_entry(command, store_directory), + Some(Command::Git(command)) => run_git(command, store_directory), Some(Command::Generate(command)) => generate_entry(command, store_directory), Some(Command::Otp(command)) => generate_otp(command, store_directory), Some(Command::Search(command)) => search_entries(command, store_directory), @@ -346,6 +359,7 @@ impl Command { Self::Edit(command) => command.json, Self::Remove(command) => command.json, Self::Move(command) => command.json, + Self::Git(command) => command.json, Self::Generate(command) => command.json, Self::Otp(command) => command.json, Self::Search(command) => command.json, @@ -424,6 +438,10 @@ fn insert_entry(command: InsertCommand, store_directory: StoreDirectory) -> Resu let content = command_entry_content(&command.entry, command.multiline, command.echo)?; InsertEntry::new(&store, &gpg).execute(&command.entry, &content, command.force)?; + auto_commit( + &store, + &format!("Added given password for {} to store.", command.entry), + )?; if command.json { print_json_insert(&command.entry)?; @@ -438,6 +456,11 @@ fn print_json_insert(entry_name: &str) -> Result<(), CliError> { Ok(()) } +fn auto_commit(store: &PasswordStore, message: &str) -> Result<(), CliError> { + GitCommand::from_environment().auto_commit(store, message)?; + Ok(()) +} + fn edit_entry(command: EditCommand, store_directory: StoreDirectory) -> Result<(), CliError> { let store = PasswordStore::open(store_directory)?; let gpg = GpgCommand::from_environment(); @@ -445,6 +468,8 @@ fn edit_entry(command: EditCommand, store_directory: StoreDirectory) -> Result<( let changed = EditEntry::new(&store, &gpg).execute(&command.entry)?; if changed { + auto_commit(&store, &format!("Edited password for {}.", command.entry))?; + if command.json { print_json_insert(&command.entry)?; } else { @@ -460,6 +485,7 @@ fn remove_entry(command: RemoveCommand, store_directory: StoreDirectory) -> Resu let store = PasswordStore::open(store_directory)?; RemoveEntry::new(&store).execute(&command.entry)?; + auto_commit(&store, &format!("Removed {} from store.", command.entry))?; if command.json { print_json_insert(&command.entry)?; @@ -491,6 +517,10 @@ fn confirm_remove(command: &RemoveCommand) -> Result<(), CliError> { fn move_entry(command: MoveCommand, store_directory: StoreDirectory) -> Result<(), CliError> { let store = PasswordStore::open(store_directory)?; MoveEntry::new(&store).execute(&command.old_entry, &command.new_entry, command.force)?; + auto_commit( + &store, + &format!("Renamed {} to {}.", command.old_entry, command.new_entry), + )?; if command.json { print_json_move(&command.old_entry, &command.new_entry)?; @@ -513,6 +543,21 @@ fn print_json_move(old_entry_name: &str, new_entry_name: &str) -> Result<(), Cli Ok(()) } +fn run_git(command: GitStoreCommand, store_directory: StoreDirectory) -> Result<(), CliError> { + let store = PasswordStore::open(store_directory)?; + let output = GitCommand::from_environment().execute(&store, &command.args)?; + + if command.json { + let json = serde_json::to_string_pretty(&output)?; + println!("{json}"); + } else { + print!("{}", output.stdout); + eprint!("{}", output.stderr); + } + + Ok(()) +} + fn generate_entry( command: GenerateCommand, store_directory: StoreDirectory, @@ -531,6 +576,10 @@ fn generate_entry( let content = format!("{password}\n"); InsertEntry::new(&store, &gpg).execute(entry, &content, command.force)?; + auto_commit( + &store, + &format!("Added generated password for {entry} to store."), + )?; } if command.json { diff --git a/src/password_store/git.rs b/src/password_store/git.rs new file mode 100644 index 0000000..338505c --- /dev/null +++ b/src/password_store/git.rs @@ -0,0 +1,159 @@ +use std::env; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde::Serialize; + +use super::{PasswordStore, PasswordStoreError}; + +const INITIAL_COMMIT_MESSAGE: &str = "Added current contents of password store."; + +pub struct GitCommand { + executable: PathBuf, +} + +impl GitCommand { + pub fn from_environment() -> Self { + Self { + executable: env::var_os("PASSWORD_STORE_GIT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("git")), + } + } + + pub fn execute( + &self, + store: &PasswordStore, + args: &[String], + ) -> Result { + if args.first().is_some_and(|arg| arg == "init") { + return self.init(store); + } + + self.ensure_repository(store.path())?; + self.run_in_store(store.path(), args) + } + + pub fn auto_commit( + &self, + store: &PasswordStore, + message: &str, + ) -> Result<(), PasswordStoreError> { + if !self.is_repository_optional(store.path())? { + return Ok(()); + } + + self.run_in_store(store.path(), &["add".to_string(), "-A".to_string()])?; + self.run_in_store( + store.path(), + &["commit".to_string(), "-m".to_string(), message.to_string()], + )?; + + Ok(()) + } + + fn init(&self, store: &PasswordStore) -> Result { + let init = self.run_in_store(store.path(), &["init".to_string()])?; + let add = self.run_in_store(store.path(), &["add".to_string(), "-A".to_string()])?; + + if !self.has_staged_changes(store.path())? { + return Ok(GitCommandOutput { + stdout: format!("{}{}", init.stdout, add.stdout), + stderr: format!("{}{}", init.stderr, add.stderr), + exit_code: init.exit_code, + }); + } + + let commit = self.run_in_store( + store.path(), + &[ + "commit".to_string(), + "-m".to_string(), + INITIAL_COMMIT_MESSAGE.to_string(), + ], + )?; + + Ok(GitCommandOutput { + stdout: format!("{}{}{}", init.stdout, add.stdout, commit.stdout), + stderr: format!("{}{}{}", init.stderr, add.stderr, commit.stderr), + exit_code: commit.exit_code, + }) + } + + fn has_staged_changes(&self, store_root: &Path) -> Result { + let output = self.raw_git(store_root, &["diff", "--cached", "--quiet"])?; + Ok(!output.status.success()) + } + + fn ensure_repository(&self, store_root: &Path) -> Result<(), PasswordStoreError> { + let output = self.raw_git(store_root, &["rev-parse", "--is-inside-work-tree"])?; + + if output.status.success() { + return Ok(()); + } + + Err(PasswordStoreError::GitRepositoryNotFound) + } + + fn is_repository_optional(&self, store_root: &Path) -> Result { + match self.raw_git(store_root, &["rev-parse", "--is-inside-work-tree"]) { + Ok(output) => Ok(output.status.success()), + Err(PasswordStoreError::GitNotFound) if !store_root.join(".git").exists() => Ok(false), + Err(error) => Err(error), + } + } + + fn run_in_store( + &self, + store_root: &Path, + args: &[String], + ) -> Result { + let output = self.raw_git(store_root, args)?; + let command_output = GitCommandOutput::from_output(output); + + if command_output.exit_code == 0 { + Ok(command_output) + } else { + Err(PasswordStoreError::GitFailed { + exit_code: command_output.exit_code, + stderr: command_output.stderr, + }) + } + } + + fn raw_git>( + &self, + store_root: &Path, + args: &[S], + ) -> Result { + Command::new(&self.executable) + .arg("-C") + .arg(store_root) + .args(args.iter().map(AsRef::as_ref)) + .output() + .map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + PasswordStoreError::GitNotFound + } else { + PasswordStoreError::Io(error) + } + }) + } +} + +#[derive(Debug, Serialize)] +pub struct GitCommandOutput { + pub stdout: String, + pub stderr: String, + pub exit_code: i32, +} + +impl GitCommandOutput { + fn from_output(output: Output) -> Self { + Self { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + exit_code: output.status.code().unwrap_or(1), + } + } +} diff --git a/src/password_store/mod.rs b/src/password_store/mod.rs index d98e239..583f0e1 100644 --- a/src/password_store/mod.rs +++ b/src/password_store/mod.rs @@ -2,6 +2,7 @@ mod decrypted_entry; mod doctor; mod edit_entry; mod entry_name; +mod git; mod gpg; mod insert_entry; mod list_entries; @@ -16,6 +17,7 @@ pub use decrypted_entry::{DecryptedEntry, EntryField}; pub use doctor::DoctorReport; pub use edit_entry::EditEntry; pub use entry_name::EntryName; +pub use git::GitCommand; pub use gpg::GpgCommand; pub use insert_entry::InsertEntry; pub use list_entries::ListEntries; diff --git a/src/password_store/store_directory.rs b/src/password_store/store_directory.rs index c309937..8958e22 100644 --- a/src/password_store/store_directory.rs +++ b/src/password_store/store_directory.rs @@ -87,6 +87,15 @@ pub enum PasswordStoreError { #[error("gpg executable was not found; install GnuPG 2.x or set PASSWORD_STORE_GPG")] GpgNotFound, + #[error("git executable was not found; install Git or set PASSWORD_STORE_GIT")] + GitNotFound, + + #[error("password store is not a git repository; run `rpass git init` first")] + GitRepositoryNotFound, + + #[error("git failed with exit code {exit_code}: {stderr}")] + GitFailed { exit_code: i32, stderr: String }, + #[error("gpg requires a passphrase; use --passphrase-stdin to provide it")] GpgPassphraseRequired, @@ -130,6 +139,9 @@ impl PasswordStoreError { Self::EditorFailed(_) => "editor_failed", Self::InvalidEntryName { .. } => "invalid_entry_name", Self::GpgNotFound => "gpg_not_found", + Self::GitNotFound => "git_not_found", + Self::GitRepositoryNotFound => "git_repository_not_found", + Self::GitFailed { .. } => "git_failed", Self::GpgPassphraseRequired => "gpg_passphrase_required", Self::GpgIdNotFound => "gpg_id_not_found", Self::GpgDecryptFailed(_) => "gpg_decrypt_failed", diff --git a/tests/git_auto_commit_command.rs b/tests/git_auto_commit_command.rs new file mode 100644 index 0000000..ab7a4f9 --- /dev/null +++ b/tests/git_auto_commit_command.rs @@ -0,0 +1,253 @@ +mod support; + +use std::fs; +use std::path::Path; + +use support::{editing_gpg_script, editing_script, encrypting_gpg_script, rpass}; + +#[test] +fn insert_auto_commits_when_store_is_git_repository() { + let store = git_store(); + let gpg = encrypting_gpg_script(store.path()); + + rpass_with_git_identity() + .env("PASSWORD_STORE_GPG", gpg) + .write_stdin("secret\n") + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "insert", + "--echo", + "example/login", + ]) + .assert() + .success(); + + assert_latest_commit_message( + store.path(), + "Added given password for example/login to store.", + ); +} + +#[test] +fn generate_auto_commits_when_password_is_saved() { + let store = git_store(); + let gpg = encrypting_gpg_script(store.path()); + + rpass_with_git_identity() + .env("PASSWORD_STORE_GPG", gpg) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "generate", + "example/generated", + "--length", + "18", + ]) + .assert() + .success(); + + assert_latest_commit_message( + store.path(), + "Added generated password for example/generated to store.", + ); +} + +#[test] +fn generate_dry_run_does_not_auto_commit() { + let store = git_store(); + let before = commit_count(store.path()); + + rpass_with_git_identity() + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "generate", + "--dry-run", + "--length", + "18", + ]) + .assert() + .success(); + + assert_eq!(commit_count(store.path()), before); +} + +#[test] +fn edit_auto_commits_only_when_content_changes() { + let store = git_store(); + write_file(store.path().join("email/work.gpg"), "encrypted\n"); + git(store.path(), ["add", "-A"]); + git(store.path(), ["commit", "-m", "seed entry"]); + let gpg = editing_gpg_script(store.path(), "old\nusername: alice\n"); + let editor = editing_script(store.path(), "new\nusername: bob\n"); + + rpass_with_git_identity() + .env("PASSWORD_STORE_GPG", gpg) + .env("EDITOR", editor) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "edit", + "email/work", + ]) + .assert() + .success(); + + assert_latest_commit_message(store.path(), "Edited password for email/work."); +} + +#[test] +fn unchanged_edit_does_not_auto_commit() { + let store = git_store(); + write_file(store.path().join("email/work.gpg"), "encrypted\n"); + git(store.path(), ["add", "-A"]); + git(store.path(), ["commit", "-m", "seed entry"]); + let before = commit_count(store.path()); + let gpg = editing_gpg_script(store.path(), "old\nusername: alice\n"); + let editor = editing_script(store.path(), "old\nusername: alice\n"); + + rpass_with_git_identity() + .env("PASSWORD_STORE_GPG", gpg) + .env("EDITOR", editor) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "edit", + "email/work", + ]) + .assert() + .success(); + + assert_eq!(commit_count(store.path()), before); +} + +#[test] +fn rm_auto_commits_when_store_is_git_repository() { + let store = git_store(); + write_file(store.path().join("example/login.gpg"), "encrypted\n"); + git(store.path(), ["add", "-A"]); + git(store.path(), ["commit", "-m", "seed entry"]); + + rpass_with_git_identity() + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "rm", + "--force", + "example/login", + ]) + .assert() + .success(); + + assert_latest_commit_message(store.path(), "Removed example/login from store."); +} + +#[test] +fn mv_auto_commits_when_store_is_git_repository() { + let store = git_store(); + write_file(store.path().join("old.gpg"), "encrypted\n"); + git(store.path(), ["add", "-A"]); + git(store.path(), ["commit", "-m", "seed entry"]); + + rpass_with_git_identity() + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "mv", + "old", + "new", + ]) + .assert() + .success(); + + assert_latest_commit_message(store.path(), "Renamed old to new."); +} + +#[test] +fn write_commands_do_not_require_git_when_store_is_not_repository() { + let store = tempfile::TempDir::new().expect("temp dir"); + write_file(store.path().join(".gpg-id"), "alice@example.invalid\n"); + let gpg = encrypting_gpg_script(store.path()); + + rpass_with_git_identity() + .env("PASSWORD_STORE_GPG", gpg) + .write_stdin("secret\n") + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "insert", + "--echo", + "example/login", + ]) + .assert() + .success(); +} + +fn git_store() -> tempfile::TempDir { + let store = tempfile::TempDir::new().expect("temp dir"); + write_file(store.path().join(".gpg-id"), "alice@example.invalid\n"); + git(store.path(), ["init"]); + git(store.path(), ["config", "user.name", "rpass tests"]); + git( + store.path(), + ["config", "user.email", "rpass-tests@example.invalid"], + ); + git(store.path(), ["add", "-A"]); + git(store.path(), ["commit", "-m", "initial store"]); + store +} + +fn rpass_with_git_identity() -> assert_cmd::Command { + let mut command = rpass(); + command + .env("GIT_AUTHOR_NAME", "rpass tests") + .env("GIT_AUTHOR_EMAIL", "rpass-tests@example.invalid") + .env("GIT_COMMITTER_NAME", "rpass tests") + .env("GIT_COMMITTER_EMAIL", "rpass-tests@example.invalid"); + command +} + +fn assert_latest_commit_message(store: &Path, expected: &str) { + assert_eq!(latest_commit_message(store), expected); +} + +fn latest_commit_message(store: &Path) -> String { + git_output(store, ["log", "-1", "--pretty=%s"]) + .trim_end_matches(['\r', '\n']) + .to_owned() +} + +fn commit_count(store: &Path) -> usize { + git_output(store, ["rev-list", "--count", "HEAD"]) + .trim() + .parse() + .expect("commit count") +} + +fn git(path: &Path, args: [&str; N]) { + let status = std::process::Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .status() + .expect("git command"); + assert!(status.success()); +} + +fn git_output(path: &Path, args: [&str; N]) -> String { + let output = std::process::Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .output() + .expect("git command"); + assert!(output.status.success()); + String::from_utf8(output.stdout).expect("git stdout") +} + +fn write_file(path: impl AsRef, content: &str) { + let path = path.as_ref(); + fs::create_dir_all(path.parent().expect("parent")).expect("parent dir"); + fs::write(path, content).expect("file"); +} diff --git a/tests/git_command.rs b/tests/git_command.rs new file mode 100644 index 0000000..9584715 --- /dev/null +++ b/tests/git_command.rs @@ -0,0 +1,195 @@ +mod support; + +use std::fs; +use std::path::Path; + +use predicates::prelude::*; +use serde_json::Value; + +use support::{missing_executable_path, rpass}; + +#[test] +fn git_init_initializes_repository_and_commits_current_store() { + let store = tempfile::TempDir::new().expect("temp dir"); + write_file(store.path().join(".gpg-id"), "alice@example.invalid\n"); + + rpass() + .env("GIT_AUTHOR_NAME", "rpass tests") + .env("GIT_AUTHOR_EMAIL", "rpass-tests@example.invalid") + .env("GIT_COMMITTER_NAME", "rpass tests") + .env("GIT_COMMITTER_EMAIL", "rpass-tests@example.invalid") + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "git", + "init", + ]) + .assert() + .success() + .stdout(predicate::str::contains( + "Added current contents of password store.", + )); + + assert!(store.path().join(".git").is_dir()); + + rpass() + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "git", + "log", + "--oneline", + "--", + ]) + .assert() + .success() + .stdout(predicate::str::contains( + "Added current contents of password store.", + )); +} + +#[test] +fn git_init_succeeds_for_empty_store_without_initial_commit() { + let store = tempfile::TempDir::new().expect("temp dir"); + + rpass() + .env("GIT_AUTHOR_NAME", "rpass tests") + .env("GIT_AUTHOR_EMAIL", "rpass-tests@example.invalid") + .env("GIT_COMMITTER_NAME", "rpass tests") + .env("GIT_COMMITTER_EMAIL", "rpass-tests@example.invalid") + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "git", + "init", + ]) + .assert() + .success(); + + assert!(store.path().join(".git").is_dir()); + + rpass() + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "git", + "status", + "--short", + ]) + .assert() + .success() + .stdout(""); +} + +#[test] +fn git_status_passes_arguments_to_git_in_store() { + let store = tempfile::TempDir::new().expect("temp dir"); + init_git_repo(store.path()); + write_file(store.path().join("example/login.gpg"), "encrypted\n"); + + rpass() + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "git", + "status", + "--short", + ]) + .assert() + .success() + .stdout(predicate::str::contains("?? example/")); +} + +#[test] +fn git_json_wraps_stdout_stderr_and_exit_code() { + let store = tempfile::TempDir::new().expect("temp dir"); + init_git_repo(store.path()); + write_file(store.path().join("example/login.gpg"), "encrypted\n"); + + let assert = rpass() + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "git", + "--json", + "status", + "--short", + ]) + .assert() + .success() + .stderr(""); + let output: Value = serde_json::from_slice(&assert.get_output().stdout).expect("json"); + + assert_eq!(output["exit_code"], 0); + assert_eq!(output["stderr"], ""); + assert!( + output["stdout"] + .as_str() + .expect("stdout") + .contains("?? example/") + ); +} + +#[test] +fn missing_git_returns_json_error() { + let store = tempfile::TempDir::new().expect("temp dir"); + + rpass() + .env("PASSWORD_STORE_GIT", missing_executable_path(store.path())) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "git", + "--json", + "status", + ]) + .assert() + .failure() + .stdout("") + .stderr(predicate::str::contains("\"code\": \"git_not_found\"")); +} + +#[test] +fn git_status_in_non_repository_returns_structured_json_error() { + let store = tempfile::TempDir::new().expect("temp dir"); + + rpass() + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "git", + "--json", + "status", + ]) + .assert() + .failure() + .stdout("") + .stderr(predicate::str::contains( + "\"code\": \"git_repository_not_found\"", + )); +} + +fn init_git_repo(path: &Path) { + git(path, ["init"]); + git(path, ["config", "user.name", "rpass tests"]); + git( + path, + ["config", "user.email", "rpass-tests@example.invalid"], + ); +} + +fn git(path: &Path, args: [&str; N]) { + let status = std::process::Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .status() + .expect("git command"); + assert!(status.success()); +} + +fn write_file(path: impl AsRef, content: &str) { + let path = path.as_ref(); + fs::create_dir_all(path.parent().expect("parent")).expect("parent dir"); + fs::write(path, content).expect("file"); +}