Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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
```
Expand All @@ -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 <args...>` 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 <args...>`
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:

Expand Down Expand Up @@ -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 <args...>`;
- 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.
13 changes: 7 additions & 6 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
53 changes: 51 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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),

Expand Down Expand Up @@ -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<String>,
}

#[derive(Debug, Parser)]
struct GenerateCommand {
entry: Option<String>,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)?;
Expand All @@ -438,13 +456,20 @@ 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();

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 {
Expand All @@ -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)?;
Expand Down Expand Up @@ -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)?;
Expand All @@ -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,
Expand All @@ -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 {
Expand Down
159 changes: 159 additions & 0 deletions src/password_store/git.rs
Original file line number Diff line number Diff line change
@@ -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<GitCommandOutput, PasswordStoreError> {
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<GitCommandOutput, PasswordStoreError> {
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<bool, PasswordStoreError> {
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<bool, PasswordStoreError> {
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<GitCommandOutput, PasswordStoreError> {
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<S: AsRef<str>>(
&self,
store_root: &Path,
args: &[S],
) -> Result<Output, PasswordStoreError> {
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),
}
}
}
Loading
Loading