use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
use anstream::eprintln;
use anyhow::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, git_cmd};
use crate::store::Store;
struct IntentToAddRestorer(Vec<PathBuf>);
struct UnstagedChangesRestorer {
root: PathBuf,
patch: Option<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(())
}
impl IntentToAddRestorer {
async fn clean(root: &Path) -> Result<Self> {
let files = git::intent_to_add_files(root).await?;
if files.is_empty() {
return Ok(Self(vec![]));
}
git_cmd()?
.current_dir(git::root()?)
.arg("rm")
.arg("--cached")
.arg("--")
.file_args(&files)
.check(true)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await?;
Ok(Self(files))
}
fn restore(&self) -> Result<()> {
if !self.0.is_empty() {
let mut cmd = Command::new(GIT.as_ref()?);
git::apply_git_work_tree(&mut cmd)
.current_dir(git::root()?)
.arg("add")
.arg("--intent-to-add")
.arg("--")
.args(&self.0)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()?;
}
Ok(())
}
}
impl Drop for IntentToAddRestorer {
fn drop(&mut self) {
if let Err(err) = self.restore() {
eprintln!(
"{}",
format!("Failed to restore intent-to-add changes: {err}").red()
);
}
}
}
impl UnstagedChangesRestorer {
async fn clean(root: &Path, patch_dir: &Path) -> Result<Self> {
let tree = git::write_tree().await?;
let mut cmd = git_cmd()?;
let output = cmd
.current_dir(git::root()?)
.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()
.await?;
if output.status.success() {
debug!("Working tree is clean");
Ok(Self {
root: root.to_path_buf(),
patch: None,
})
} else if output.status.code() == Some(1) {
if output.stdout.trim_ascii().is_empty() {
trace!("diff-index status code 1 with empty stdout");
Ok(Self {
root: root.to_path_buf(),
patch: None,
})
} else {
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);
debug!("Unstaged changes detected");
eprintln!(
"{}",
format!(
"Unstaged changes detected. Temporarily saving them to `{}`",
patch_path.user_display()
)
.yellow()
.bold()
);
fs_err::write(&patch_path, output.stdout)?;
debug!("Cleaning working tree");
Self::checkout_working_tree(root)?;
Ok(Self {
root: root.to_path_buf(),
patch: Some(patch_path),
})
}
} else {
Err(cmd.check_status(output.status).unwrap_err().into())
}
}
fn checkout_working_tree(root: &Path) -> Result<()> {
let mut cmd = Command::new(GIT.as_ref()?);
let output = git::apply_git_work_tree(&mut cmd)
.current_dir(git::root()?)
.arg("-c")
.arg("submodule.recurse=0")
.arg("checkout")
.arg("--")
.arg(root)
.env(EnvVars::PREK_INTERNAL__SKIP_POST_CHECKOUT, "1")
.output()?;
if output.status.success() {
Ok(())
} else {
Err(anyhow::anyhow!(
"Failed to checkout working tree: {output:?}"
))
}
}
fn git_apply(patch: &Path) -> Result<()> {
let mut cmd = Command::new(GIT.as_ref()?);
let output = git::apply_git_work_tree(&mut cmd)
.current_dir(git::root()?)
.arg("apply")
.arg("--whitespace=nowarn")
.arg(patch)
.output()?;
if output.status.success() {
Ok(())
} else {
Err(anyhow::anyhow!("Failed to apply the patch: {output:?}"))
}
}
fn restore(&self) -> Result<()> {
let Some(patch) = self.patch.as_ref() else {
return Ok(());
};
if let Err(e) = Self::git_apply(patch) {
error!("{e}");
eprintln!(
"{}",
"Hook changes conflicted with the saved unstaged changes. Reverting the hook changes".red().bold()
);
Self::checkout_working_tree(&self.root)?;
Self::git_apply(patch)?;
}
eprintln!(
"{}",
format!("Restored unstaged changes from `{}`", patch.user_display())
.yellow()
.bold()
);
Ok(())
}
}
impl Drop for UnstagedChangesRestorer {
fn drop(&mut self) {
if let Err(err) = self.restore() {
eprintln!(
"{}",
format!("Failed to restore unstaged changes: {err}").red()
);
}
}
}
pub struct WorkTreeKeeper {
state: Arc<Mutex<Option<WorkTreeState>>>,
}
struct WorkTreeState {
unstaged_changes: UnstagedChangesRestorer,
intent_to_add: IntentToAddRestorer,
}
impl Drop for WorkTreeKeeper {
fn drop(&mut self) {
let mut state = self.state.lock().unwrap();
drop(state.take());
}
}
impl WorkTreeKeeper {
pub async fn clean(store: &Store, root: &Path) -> Result<Self> {
let intent_to_add = IntentToAddRestorer::clean(root).await?;
let unstaged_changes = UnstagedChangesRestorer::clean(root, &store.patches_dir()).await?;
let state = WorkTreeState {
unstaged_changes,
intent_to_add,
};
let state = Arc::new(Mutex::new(Some(state)));
let cleanup_state = Arc::clone(&state);
add_cleanup(move || {
let mut state = cleanup_state.lock().unwrap();
drop(state.take());
});
Ok(Self { state })
}
}