use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use git_xcrypt::commands::status::Verdict;
use git_xcrypt::commands::sync::Outcome;
use git_xcrypt::git::repo::{Repo, git_spelling};
use git_xcrypt::util::exit;
use git_xcrypt::{Result, commands};
const EXIT_CODES: &str = "\
Exit codes:
0 success
1 usage error, or a failure with no better code
2 configuration or a state conflict — including a `status` run that found
the setup enforcing nothing, and a stale section under `sync --check`
3 no repository key
4 bad format: magic, key id, unknown flag bit, or a failed tag
5 `status` found an exposure
6 `status` could not tell
A CI gate should treat 2, 5 and 6 alike as a failure.";
#[derive(Debug, Parser)]
#[command(
name = "git-xcrypt",
version,
about,
long_about = None,
after_long_help = EXIT_CODES,
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Init,
Sync {
#[arg(long)]
check: bool,
#[arg(long)]
global: bool,
#[arg(long, conflicts_with = "global")]
ignorecase: bool,
},
ExportKey {
#[arg(required_unless_present = "stdout", conflicts_with = "stdout")]
path: Option<PathBuf>,
#[arg(long)]
stdout: bool,
#[arg(long)]
force: bool,
},
Unlock {
#[arg(conflicts_with = "key")]
path: Option<PathBuf>,
#[arg(long, value_name = "TEXT")]
key: Option<String>,
#[arg(long)]
key_only: bool,
},
Lock {
#[arg(long)]
yes: bool,
},
Status {
#[arg(long)]
fix: bool,
},
Process,
#[command(disable_help_flag = true)]
Diff {
#[arg(allow_hyphen_values = true)]
path: PathBuf,
},
}
fn main() -> ExitCode {
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(err) => {
let usage = !matches!(
err.kind(),
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
);
let _ = err.print();
return if usage {
ExitCode::from(exit::USAGE)
} else {
ExitCode::SUCCESS
};
}
};
match cli.command {
Command::Init => report(run_init()),
Command::Sync {
check,
global,
ignorecase,
} => run_sync(check, global, ignorecase),
Command::ExportKey {
path,
stdout,
force,
} => report(run_export_key(path.as_deref(), stdout, force)),
Command::Unlock {
path,
key,
key_only,
} => report(run_unlock(path.as_deref(), key.as_deref(), key_only)),
Command::Lock { yes } => run_lock(yes),
Command::Status { fix } => run_status(fix),
Command::Process => report(commands::process::run()),
Command::Diff { path } => report(run_diff(&path)),
}
}
fn run_diff(path: &std::path::Path) -> Result<()> {
use std::io::Write as _;
let outcome = commands::diff::run(path)?;
if let Some(warning) = outcome.warning {
eprintln!("git-xcrypt: {warning}");
}
let mut out = std::io::stdout().lock();
out.write_all(&outcome.content)?;
out.flush()?;
Ok(())
}
fn report(result: Result<()>) -> ExitCode {
match result {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("git-xcrypt: {err}");
ExitCode::from(err.exit_code())
}
}
}
fn run_init() -> Result<()> {
let repo = Repo::discover_from_cwd()?;
let report = commands::init::run(&repo)?;
if report.key_created {
eprintln!(
"git-xcrypt: generated a repository key in {}",
repo.key_path().display()
);
}
if report.config_file_created {
eprintln!(
"git-xcrypt: created {}",
repo.xcrypt_config_path().display()
);
}
if report.config_written {
eprintln!(
"git-xcrypt: registered the filter in {}",
repo.config_path().display()
);
}
if report.attributes_written {
eprintln!("git-xcrypt: updated {}", repo.attributes_path().display());
}
if !report.changed_anything() {
eprintln!("git-xcrypt: already set up; nothing to do");
}
for warning in &report.warnings {
eprintln!("git-xcrypt: {warning}");
}
Ok(())
}
fn run_export_key(path: Option<&std::path::Path>, stdout: bool, force: bool) -> Result<()> {
let repo = Repo::discover_from_cwd()?;
if stdout {
let key_id = commands::export_key::to_stdout(&repo)?;
eprintln!(
"git-xcrypt: wrote key {} to standard output",
git_xcrypt::format_key_id(&key_id)
);
eprintln!(
"git-xcrypt: whatever is on the other end of that pipe now holds the only \
way back into this repository's history. If you redirected it to a file, \
nothing checked where that file is — a path inside the working tree is one \
`git add -A` from a commit."
);
return Ok(());
}
let path = path.expect("clap requires a path unless --stdout is given");
let report = commands::export_key::run(&repo, path, force)?;
eprintln!(
"git-xcrypt: wrote key {} to {}",
git_xcrypt::format_key_id(&report.key_id),
report.path.display()
);
eprintln!(
"git-xcrypt: this file is the only way back into this repository's history — \
keep it somewhere you can still read it after this machine is gone"
);
Ok(())
}
fn run_unlock(path: Option<&std::path::Path>, key: Option<&str>, key_only: bool) -> Result<()> {
let repo = Repo::discover_from_cwd()?;
if key.is_some() {
eprintln!(
"git-xcrypt: the key was given on the command line, so it is visible in the \
process list while this runs and your shell has already recorded it. Clear \
that history entry, or pass a file next time."
);
}
let source = match (path, key) {
(Some(path), _) => Some(commands::unlock::KeySource::File(path)),
(None, Some(text)) => Some(commands::unlock::KeySource::Material(text)),
(None, None) => None,
};
let report = commands::unlock::run(&repo, source, key_only)?;
let key_id = git_xcrypt::format_key_id(&report.key_id);
if report.key_imported {
eprintln!("git-xcrypt: imported key {key_id}");
}
if report.config_written {
eprintln!(
"git-xcrypt: registered the filter in {}",
repo.config_path().display()
);
}
if report.attributes_written {
eprintln!("git-xcrypt: updated {}", repo.attributes_path().display());
}
for warning in &report.warnings {
eprintln!("git-xcrypt: {warning}");
}
for path in &report.decrypted {
eprintln!("git-xcrypt: decrypted {}", git_spelling(path));
}
let unreadable = match report.unreadable.len() {
0 => String::new(),
count => format!(", {count} could not be read and may still be encrypted"),
};
if key_only {
eprintln!(
"git-xcrypt: key {key_id} is in place and this repository filters; \
nothing was decrypted. `git-xcrypt unlock` opens the working tree."
);
} else if report.decrypted.is_empty() {
eprintln!("git-xcrypt: unlocked with key {key_id}; nothing was encrypted{unreadable}");
} else {
eprintln!(
"git-xcrypt: unlocked with key {key_id}; {} file(s) are now in the clear{unreadable}",
report.decrypted.len()
);
}
Ok(())
}
fn run_lock(assume_yes: bool) -> ExitCode {
match lock_and_describe(assume_yes) {
Ok(code) => code,
Err(err) => {
eprintln!("git-xcrypt: {err}");
ExitCode::from(err.exit_code())
}
}
}
fn lock_and_describe(assume_yes: bool) -> Result<ExitCode> {
use commands::lock::Outcome;
let repo = Repo::discover_from_cwd()?;
let outcome = if assume_yes {
commands::lock::run(&repo, &mut commands::lock::Assumed::new(std::io::stderr()))?
} else {
commands::lock::run(
&repo,
&mut commands::lock::Ask::new(std::io::stdin().lock(), std::io::stderr()),
)?
};
let report = match outcome {
Outcome::Aborted => {
eprintln!(
"git-xcrypt: aborted. The key is still here and nothing in the working \
tree was changed."
);
return Ok(ExitCode::from(exit::USAGE));
}
Outcome::Locked(report) => report,
};
for warning in &report.warnings {
eprintln!("git-xcrypt: {warning}");
}
if report.config_written {
eprintln!(
"git-xcrypt: repaired the filter registration in {}",
repo.config_path().display()
);
}
if report.diff_driver_removed {
eprintln!(
"git-xcrypt: unregistered the diff driver; `git diff` will report \
`Binary files differ` until this repository is unlocked again"
);
}
if report.attributes_written {
eprintln!("git-xcrypt: updated {}", repo.attributes_path().display());
}
for path in &report.swept {
eprintln!(
"git-xcrypt: removed {}, left behind by an interrupted run",
git_spelling(path)
);
}
for path in &report.encrypted {
eprintln!("git-xcrypt: encrypted {}", git_spelling(path));
}
let key_id = git_xcrypt::format_key_id(&report.key_id);
let already = report.declared - report.encrypted.len();
let scope = match (report.declared, report.encrypted.len()) {
(0, _) => "no file here is declared for encryption".to_string(),
(_, 0) => format!("all {already} declared file(s) were already encrypted"),
(_, written) => format!("{written} file(s) are now encrypted"),
};
eprintln!(
"git-xcrypt: locked; {scope} and key {key_id} has been deleted from this \
repository"
);
eprintln!(
"git-xcrypt: run `git-xcrypt unlock <key-file>` with your copy of key {key_id} \
to open it again"
);
Ok(ExitCode::SUCCESS)
}
fn run_status(fix: bool) -> ExitCode {
match status_and_describe(fix) {
Ok(code) => code,
Err(err) => {
eprintln!("git-xcrypt: {err}");
ExitCode::from(err.exit_code())
}
}
}
fn status_and_describe(fix: bool) -> Result<ExitCode> {
use std::io::Write as _;
let repo = Repo::discover_from_cwd()?;
let report = commands::status::run(&repo, fix)?;
for warning in &report.warnings {
eprintln!("git-xcrypt: {warning}");
}
let mut out = std::io::stdout().lock();
write!(out, "{report}")?;
out.flush()?;
Ok(match report.verdict() {
Verdict::Clean => ExitCode::SUCCESS,
Verdict::Undetermined => ExitCode::from(exit::UNDETERMINED),
Verdict::Exposed => ExitCode::from(exit::EXPOSED),
Verdict::Misconfigured => ExitCode::from(exit::CONFIG),
})
}
fn run_sync(check: bool, global: bool, ignorecase: bool) -> ExitCode {
match sync_and_describe(check, global, ignorecase) {
Ok(code) => code,
Err(err) => {
eprintln!("git-xcrypt: {err}");
ExitCode::from(err.exit_code())
}
}
}
fn sync_and_describe(check: bool, global: bool, ignorecase: bool) -> Result<ExitCode> {
let rendering = if global {
git_xcrypt::git::attributes::Rendering::Global
} else {
git_xcrypt::git::attributes::Rendering::PerPattern {
fold_case: ignorecase,
}
};
let repo = Repo::discover_from_cwd()?;
let report = commands::sync::run(&repo, check, rendering)?;
for warning in &report.warnings {
eprintln!("git-xcrypt: {warning}");
}
if report.foreign > 0 {
eprintln!(
"git-xcrypt: {} line(s) outside the managed section set `filter`, `text`, \
`eol` or `crlf`. Git takes the last match, so one of them may outrank \
what this just wrote — `git-xcrypt status` resolves the attributes the \
way git does and says whether any declared path is affected.",
report.foreign
);
}
let attributes = repo.attributes_path().display().to_string();
Ok(match report.outcome {
Outcome::Updated => {
eprintln!("git-xcrypt: updated {attributes}");
ExitCode::SUCCESS
}
Outcome::UpToDate => {
eprintln!("git-xcrypt: {attributes} was already up to date; nothing changed");
ExitCode::SUCCESS
}
Outcome::Stale => {
eprintln!(
"git-xcrypt: {attributes} is out of date with {}; run `git-xcrypt sync`",
git_xcrypt::git::repo::CONFIG_FILE
);
ExitCode::from(exit::CONFIG)
}
})
}