use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use anstream::eprintln;
use anyhow::{Context, Result};
use owo_colors::OwoColorize;
use prek_consts::env_vars::EnvVars;
use tracing::{debug, error, trace};
use crate::cleanup::add_cleanup;
use crate::fs::Simplified;
use crate::git::{self, git_cmd};
use crate::process::Cmd;
use crate::store::Store;
struct SavedPatch {
root: PathBuf,
tree: String,
path: PathBuf,
}
fn ensure_patches_dir(path: &Path) -> Result<()> {
fs_err::create_dir_all(path)?;
#[cfg(unix)]
{
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
let _ = fs_err::set_permissions(path, Permissions::from_mode(0o700));
}
Ok(())
}
fn git_command() -> Result<Cmd> {
let mut cmd = git_cmd()?;
cmd.current_dir(git::root()?);
Ok(cmd)
}
impl SavedPatch {
fn save(root: &Path, patch_dir: &Path) -> Result<Option<Self>> {
let tree = git::write_tree()?;
let output = git_command()?
.arg("diff-index")
.arg("--binary")
.arg("--exit-code")
.hidden_args([
"--ignore-submodules",
"--no-color",
"--no-ext-diff",
"--no-textconv",
"--no-relative",
])
.arg(&tree)
.arg("--")
.arg(root)
.check(false)
.output_sync()?;
match output.status.code() {
Some(0) => {
debug!("Working tree is clean");
return Ok(None);
}
Some(1) if output.stdout.trim_ascii().is_empty() => {
trace!("diff-index status code 1 with empty stdout");
return Ok(None);
}
Some(1) => {}
_ => anyhow::bail!(
"Failed to save unstaged changes:\n{}",
String::from_utf8_lossy(&output.stderr).trim()
),
}
let now = std::time::SystemTime::now();
let pid = std::process::id();
let patch_name = format!(
"{}-{}.patch",
now.duration_since(std::time::UNIX_EPOCH)?.as_millis(),
pid
);
ensure_patches_dir(patch_dir)?;
let patch_path = patch_dir.join(&patch_name);
eprintln!(
"{}",
format!(
"Unstaged changes detected. Temporarily saving them to `{}`",
patch_path.user_display()
)
.yellow()
.bold()
);
let mut patch_file = fs_err::File::create(&patch_path)?;
writeln!(patch_file, "# prek pre-hook index tree: {tree}")?;
patch_file.write_all(&output.stdout)?;
Ok(Some(Self {
root: root.to_path_buf(),
tree,
path: patch_path,
}))
}
fn checkout(&self) -> Result<()> {
git_command()?
.args(["-c", "submodule.recurse=0", "checkout", "--"])
.arg(&self.root)
.env(EnvVars::PREK_INTERNAL__SKIP_POST_CHECKOUT, "1")
.output_sync()
.context("Failed to checkout working tree")?;
Ok(())
}
fn restore_from_tree(&self) -> Result<()> {
git_command()?
.args(["reset", "--quiet", &self.tree, "--"])
.arg(&self.root)
.output_sync()
.context("Failed to restore the pre-hook index")?;
self.checkout()?;
self.apply()
}
fn apply(&self) -> Result<()> {
git_command()?
.args(["apply", "--whitespace=nowarn"])
.arg(&self.path)
.output_sync()
.context("Failed to apply the patch")?;
Ok(())
}
fn restore(&self) -> Result<RestoreOutcome> {
let outcome = if let Err(e) = self.apply() {
error!("{e}");
eprintln!(
"{}",
"Hook changes conflicted with the saved unstaged changes. Reverting the hook changes".red().bold()
);
self.restore_from_tree().with_context(|| {
format!(
"Failed to restore unstaged changes.\n\
Your changes are saved in `{}`.\n\
Pre-hook index tree: {}",
self.path.user_display(),
self.tree,
)
})?;
RestoreOutcome::HookChangesReverted
} else {
RestoreOutcome::Restored
};
eprintln!(
"{}",
format!(
"Restored unstaged changes from `{}`",
self.path.user_display()
)
.yellow()
.bold()
);
Ok(outcome)
}
}
pub(super) enum RestoreOutcome {
Restored,
HookChangesReverted,
}
pub(super) struct WorktreeStash {
state: Arc<Mutex<Option<PendingChanges>>>,
}
struct PendingChanges {
intent_to_add: Vec<PathBuf>,
patch: Option<SavedPatch>,
}
impl PendingChanges {
fn prepare(&mut self, root: &Path, patch_dir: &Path) -> Result<()> {
if !self.intent_to_add.is_empty() {
git_command()?
.args(["rm", "--cached", "--"])
.args(&self.intent_to_add)
.output_sync()
.context("Failed to clear intent-to-add changes")?;
}
self.patch = SavedPatch::save(root, patch_dir)?;
if let Some(patch) = &self.patch {
debug!("Cleaning working tree");
patch.checkout()?;
}
Ok(())
}
fn restore(self) -> Result<RestoreOutcome> {
let unstaged = match &self.patch {
Some(patch) => patch.restore(),
None => Ok(RestoreOutcome::Restored),
};
let intent = self.restore_intent();
match (unstaged, intent) {
(result, Ok(())) => result,
(Ok(_), Err(err)) => Err(err),
(Err(err), Err(intent_err)) => {
Err(anyhow::anyhow!("{err:#}\n\nAdditionally:\n{intent_err:#}"))
}
}
}
fn restore_intent(&self) -> Result<()> {
if !self.intent_to_add.is_empty() {
git_command()?
.args(["add", "--intent-to-add", "--"])
.args(&self.intent_to_add)
.output_sync()
.context("Failed to restore intent-to-add changes")?;
}
Ok(())
}
}
impl Drop for WorktreeStash {
fn drop(&mut self) {
if let Err(err) = Self::restore_once(&self.state) {
eprintln!("{}", format!("{err:#}").red());
}
}
}
impl WorktreeStash {
pub fn restore(self) -> Result<RestoreOutcome> {
Self::restore_once(&self.state)
}
fn restore_once(state: &Mutex<Option<PendingChanges>>) -> Result<RestoreOutcome> {
let mut guard = state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(state) = guard.take() else {
return Ok(RestoreOutcome::Restored);
};
state.restore()
}
pub fn save(store: &Store, root: &Path, mut intent_to_add: Vec<PathBuf>) -> Result<Self> {
intent_to_add.retain(|path| path.starts_with(root));
let stash = Self {
state: Arc::new(Mutex::new(Some(PendingChanges {
intent_to_add,
patch: None,
}))),
};
let state = Arc::clone(&stash.state);
add_cleanup(move || {
if let Err(err) = Self::restore_once(&state) {
eprintln!("{}", format!("{err:#}").red());
}
});
let result = {
let mut guard = stash
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let state = guard.as_mut().context("Worktree cleanup was interrupted")?;
state.prepare(root, &store.patches_dir())
};
if let Err(err) = result {
return match stash.restore() {
Ok(_) => Err(err),
Err(restore_err) => Err(anyhow::anyhow!(
"{err:#}\n\nRecovery also failed:\n{restore_err:#}"
)),
};
}
Ok(stash)
}
}