#[cfg_attr(not(any(target_os = "linux", target_os = "macos")), allow(dead_code))]
mod backend;
mod diff;
mod session;
mod state;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use state::StateRoots;
#[cfg(target_os = "macos")]
const SCOPE_NOTE: &str = "\
Guarantee boundary: the sandbox covers filesystem writes under the current \
directory tree only. Writes outside it (/tmp, $HOME, other mounts), network \
side effects, and spawned processes are NOT captured and cannot be undone.\n\
On macOS the model is snapshot-restore: the command runs against your real \
files and `oops undo` puts them back — between run and undo/commit the tree \
holds the command's changes, and cloud sync clients or file watchers can \
observe (and may propagate) that transient state.";
#[cfg(not(target_os = "macos"))]
const SCOPE_NOTE: &str = "\
Guarantee boundary: the sandbox covers filesystem writes under the current \
directory tree only. Writes outside it (/tmp, $HOME, other mounts), network \
side effects, and spawned processes are NOT captured and cannot be undone.";
#[derive(Parser)]
#[command(
name = "oops",
version,
about = "Undo for your terminal: any command can be undone."
)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
#[command(after_help = SCOPE_NOTE)]
Run {
#[arg(value_name = "COMMAND")]
command: String,
},
Diff {
#[arg(long)]
porcelain: bool,
},
Undo,
Commit,
#[command(name = "__exec", hide = true)]
Exec {
#[arg(long)]
target: PathBuf,
#[arg(long)]
upper: PathBuf,
#[arg(long)]
work: PathBuf,
#[arg(long)]
marker: PathBuf,
#[arg(value_name = "COMMAND")]
command: String,
},
#[command(name = "__gc", hide = true)]
Gc,
}
fn main() {
let cli = Cli::parse();
let code = match dispatch(cli.cmd) {
Ok(code) => code,
Err(e) => {
eprintln!("oops: error: {e:#}");
1
}
};
std::process::exit(code);
}
#[allow(unused_variables)]
fn dispatch(cmd: Cmd) -> Result<i32> {
match cmd {
Cmd::Run { command } => run(&command),
Cmd::Diff { porcelain } => diff_cmd(porcelain),
Cmd::Undo => undo(),
Cmd::Commit => commit(),
Cmd::Gc => {
session::gc_sweep(&StateRoots::load()?)?;
Ok(0)
}
Cmd::Exec {
target,
upper,
work,
marker,
command,
} => {
#[cfg(target_os = "linux")]
{
backend::overlayfs::enter_and_exec(&target, &upper, &work, &marker, &command)?;
unreachable!("enter_and_exec returned Ok without exec")
}
#[cfg(not(target_os = "linux"))]
bail!("__exec is only available on Linux")
}
}
}
fn target_dir() -> Result<PathBuf> {
std::env::current_dir()?
.canonicalize()
.context("cannot resolve the current directory")
}
fn pending_session(roots: &StateRoots) -> Result<session::Session> {
let mut candidates: Vec<PathBuf> = Vec::new();
if let Ok(cwd) = target_dir() {
candidates.push(cwd);
}
if let Some(pwd) = std::env::var_os("PWD").map(PathBuf::from) {
if pwd.is_absolute() && !candidates.contains(&pwd) {
candidates.push(pwd);
}
}
if candidates.is_empty() {
bail!("cannot resolve the current directory (and $PWD is not set)");
}
for candidate in &candidates {
if let Some(sess) = session::find_for_target(roots, candidate)? {
return Ok(sess);
}
}
bail!(
"no pending sandbox for {} — run something first: oops run \"<command>\"",
candidates[0].display()
)
}
fn run(command: &str) -> Result<i32> {
let backend = backend::select()?;
let mut roots = StateRoots::load()?;
if let Err(e) = session::gc_sweep(&roots) {
eprintln!("oops: warning: gc sweep failed: {e:#}");
}
let target = target_dir()?;
if target == Path::new("/") {
bail!("refusing to sandbox the filesystem root");
}
let root = roots.root_for_target(&target)?;
if target.starts_with(&root) || root.starts_with(&target) {
bail!("refusing to sandbox a directory that contains or lives in the oops state directory");
}
session::ensure_no_pending(&roots, &target)?;
let sess = session::create(&root, &target, command, backend.kind())?;
let sandbox = backend::sandbox_of(&sess.dir, &sess.record)?;
let status = match backend.exec(&sandbox, command) {
Ok(status) => status,
Err(e) => {
if session::move_to_trash(&roots, &sess.root, &sess.dir).is_ok() {
session::spawn_background_gc();
}
return Err(e);
}
};
let code = exit_code(status);
let mut record = sess.record;
record.exit_code = Some(code);
session::save(&sess.dir, &record)?;
eprintln!(
"oops: sandboxed (exit {code}). `oops diff` to inspect, `oops undo` to discard, `oops commit` to keep."
);
Ok(code)
}
fn diff_cmd(porcelain: bool) -> Result<i32> {
let roots = StateRoots::load()?;
let sess = pending_session(&roots)?;
let backend = backend::for_record(&sess.record)?;
let sandbox = backend::sandbox_of(&sess.dir, &sess.record)?;
ensure_not_stale(backend.as_ref(), &sandbox, &sess)?;
let changes = backend.changes(&sandbox)?;
if porcelain {
print!("{}", diff::render_porcelain(&changes));
} else {
use std::io::IsTerminal;
let color = std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none();
print!("{}", diff::render_human(&changes, color));
}
Ok(0)
}
fn undo() -> Result<i32> {
let roots = StateRoots::load()?;
let sess = pending_session(&roots)?;
let backend = backend::for_record(&sess.record)?;
let sandbox = backend::sandbox_of(&sess.dir, &sess.record)?;
backend.restore(&sandbox)?;
session::move_to_trash(&roots, &sess.root, &sess.dir)?;
session::spawn_background_gc();
eprintln!(
"oops: undone 💨 (discarded the sandbox from `oops run \"{}\"`)",
sess.record.command
);
Ok(0)
}
fn commit() -> Result<i32> {
let roots = StateRoots::load()?;
let sess = pending_session(&roots)?;
let backend = backend::for_record(&sess.record)?;
let sandbox = backend::sandbox_of(&sess.dir, &sess.record)?;
ensure_not_stale(backend.as_ref(), &sandbox, &sess)
.context("refusing to commit a stale sandbox")?;
backend.merge(&sandbox)?;
session::move_to_trash(&roots, &sess.root, &sess.dir)?;
session::spawn_background_gc();
eprintln!(
"oops: committed `oops run \"{}\"` to the real files.",
sess.record.command
);
Ok(0)
}
fn ensure_not_stale(
backend: &dyn backend::SnapshotBackend,
sandbox: &backend::Sandbox,
sess: &session::Session,
) -> Result<()> {
if backend.is_stale(sandbox) {
bail!(
"the sandbox state for {} is gone (stale session)",
sess.record.target.display()
);
}
Ok(())
}
#[cfg(unix)]
fn exit_code(status: std::process::ExitStatus) -> i32 {
use std::os::unix::process::ExitStatusExt;
status
.code()
.unwrap_or_else(|| 128 + status.signal().unwrap_or(1))
}
#[cfg(not(unix))]
fn exit_code(status: std::process::ExitStatus) -> i32 {
status.code().unwrap_or(1)
}