use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::Mutex;
use crate::ui::{error_sign, warning_sign};
static HELD: AtomicBool = AtomicBool::new(false);
static ENTER_LOCK: Mutex<()> = Mutex::new(());
pub const STORE: &str = "amont-held";
const INDEX: &str = "index";
const FILES: &str = "files";
const FORMAT: &str = "amont-held-v1";
const EXPECTED: &str = "expected";
const PRESERVED: &str = "amont-preserved";
fn content_hash(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
h ^= u64::from(b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Held {
Modified { rel: String, mode: Option<u32> },
Absent { rel: String },
Symlink { rel: String, target: String },
}
fn safe_rel(rel: &str) -> Option<std::path::PathBuf> {
use std::path::Component;
if rel.is_empty() {
return None;
}
let mut out = std::path::PathBuf::new();
for c in Path::new(rel).components() {
match c {
Component::Normal(part) => out.push(part),
_ => return None,
}
}
(!out.as_os_str().is_empty()).then_some(out)
}
fn push_field(out: &mut Vec<u8>, s: &str) {
out.extend_from_slice(s.as_bytes());
out.push(0);
}
fn encode_index(entries: &[Held]) -> Vec<u8> {
let mut out = Vec::new();
push_field(&mut out, FORMAT);
for e in entries {
match e {
Held::Modified { rel, mode } => {
push_field(&mut out, "file");
push_field(&mut out, rel);
push_field(&mut out, &mode.map(|m| m.to_string()).unwrap_or_default());
}
Held::Absent { rel } => {
push_field(&mut out, "absent");
push_field(&mut out, rel);
}
Held::Symlink { rel, target } => {
push_field(&mut out, "symlink");
push_field(&mut out, rel);
push_field(&mut out, target);
}
}
}
out
}
fn parse_index(raw: &[u8]) -> Result<Vec<Held>, String> {
let mut fields: Vec<&[u8]> = raw.split(|b| *b == 0).collect();
if fields.last().is_some_and(|f| f.is_empty()) {
fields.pop();
}
let text = |f: &[u8]| -> Result<String, String> {
std::str::from_utf8(f)
.map(|s| s.to_string())
.map_err(|_| "held store index is not valid UTF-8".to_string())
};
let header = fields
.first()
.ok_or_else(|| "held store index is empty".to_string())?;
if *header != FORMAT.as_bytes() {
return Err(format!(
"held store index is not {FORMAT} — refusing to guess at its shape"
));
}
let mut out = Vec::new();
let mut i = 1;
let take = |i: &mut usize, what: &str| -> Result<String, String> {
let f = fields
.get(*i)
.ok_or_else(|| format!("held store index ends mid-record, expected {what}"))?;
*i += 1;
text(f)
};
while i < fields.len() {
let kind = take(&mut i, "a record kind")?;
match kind.as_str() {
"file" => {
let rel = take(&mut i, "a path")?;
let mode = take(&mut i, "a mode")?;
let mode = if mode.is_empty() {
None
} else {
Some(
mode.parse::<u32>()
.map_err(|_| format!("held store index has a bad mode: {mode:?}"))?,
)
};
out.push(Held::Modified { rel, mode });
}
"absent" => out.push(Held::Absent {
rel: take(&mut i, "a path")?,
}),
"symlink" => {
let rel = take(&mut i, "a path")?;
let target = take(&mut i, "a link target")?;
out.push(Held::Symlink { rel, target });
}
other => return Err(format!("held store index has an unknown record: {other:?}")),
}
}
Ok(out)
}
#[cfg(unix)]
fn mode_of(meta: &std::fs::Metadata) -> Option<u32> {
use std::os::unix::fs::PermissionsExt;
Some(meta.permissions().mode())
}
#[cfg(not(unix))]
fn mode_of(_meta: &std::fs::Metadata) -> Option<u32> {
None }
#[cfg(unix)]
fn set_mode(path: &Path, mode: u32) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
}
#[cfg(not(unix))]
fn set_mode(_path: &Path, _mode: u32) -> std::io::Result<()> {
Ok(())
}
pub struct StagedOnly {
held: bool,
}
impl StagedOnly {
pub fn enter() -> Result<StagedOnly, String> {
let conflicted: Vec<String> =
crate::git::stdout_paths(&["diff", "--name-only", "--diff-filter=U"])
.unwrap_or_default();
if !conflicted.is_empty() {
return Err(format!(
"{} unmerged paths — resolve and stage them first:\n {}",
error_sign(),
conflicted.join("\n ")
));
}
let changed: Vec<String> =
crate::git::stdout_paths(&["diff", "--name-only"]).unwrap_or_default();
if changed.is_empty() {
return Ok(StagedOnly { held: false });
}
let in_progress = crate::git_states_in_progress();
if !in_progress.is_empty() {
println!(
"{} {} in progress — checks see the working tree, not just the index",
warning_sign(),
in_progress
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(" and ")
);
return Ok(StagedOnly { held: false });
}
let Some(store) = store_dir() else {
return Ok(StagedOnly { held: false });
};
if has_contents(&store) {
return Err(format!(
"{} a previous stash was left behind at {} — recover it with \
`amont restore`, then retry",
error_sign(),
store.display()
));
}
let root = crate::hooks::common::repo_root();
let root = Path::new(&root);
if std::fs::create_dir_all(&store).is_err() {
return Err(held_nothing(&store));
}
let mut entries: Vec<Held> = Vec::with_capacity(changed.len());
for rel in &changed {
let Some(relp) = safe_rel(rel) else {
return Err(held_nothing(&store));
};
let from = root.join(&relp);
match std::fs::symlink_metadata(&from) {
Ok(meta) if meta.file_type().is_symlink() => match std::fs::read_link(&from) {
Ok(target) => entries.push(Held::Symlink {
rel: rel.clone(),
target: target.to_string_lossy().into_owned(),
}),
Err(_) => return Err(held_nothing(&store)),
},
Ok(meta) => {
let Ok(bytes) = std::fs::read(&from) else {
return Err(held_nothing(&store));
};
let to = store.join(FILES).join(&relp);
if let Some(parent) = to.parent() {
if std::fs::create_dir_all(parent).is_err() {
return Err(held_nothing(&store));
}
}
if std::fs::write(&to, bytes).is_err() {
return Err(held_nothing(&store));
}
entries.push(Held::Modified {
rel: rel.clone(),
mode: mode_of(&meta),
});
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
entries.push(Held::Absent { rel: rel.clone() })
}
Err(_) => return Err(held_nothing(&store)),
}
}
if std::fs::write(store.join(INDEX), encode_index(&entries)).is_err() {
return Err(held_nothing(&store));
}
{
let _guard = ENTER_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let mut spec = String::new();
for p in &changed {
spec.push_str(":(literal)");
spec.push_str(p);
spec.push('\0');
}
if crate::git::stdout_piped_raw(
&["checkout", "--pathspec-from-file=-", "--pathspec-file-nul"],
&spec,
)
.is_none()
{
let _ = std::fs::remove_dir_all(&store);
return Err(format!(
"{} could not set the unstaged changes aside; nothing was changed",
error_sign()
));
}
let mut expected = String::new();
for e in &entries {
if let Held::Modified { rel, .. } = e {
if let Some(relp) = safe_rel(rel) {
if let Ok(bytes) = std::fs::read(root.join(&relp)) {
expected.push_str(rel);
expected.push('\0');
expected.push_str(&format!("{:016x}", content_hash(&bytes)));
expected.push('\0');
}
}
}
}
let _ = std::fs::write(store.join(EXPECTED), expected);
HELD.store(true, Ordering::SeqCst);
}
Ok(StagedOnly { held: true })
}
pub fn restore() {
let _guard = ENTER_LOCK.lock().unwrap_or_else(|p| p.into_inner());
if !HELD.swap(false, Ordering::SeqCst) {
return;
}
let Some(store) = store_dir() else {
return;
};
if !store.is_dir() {
return;
}
let root = crate::hooks::common::repo_root();
match put_back(&store, Path::new(&root)) {
Ok(()) => {
let _ = std::fs::remove_dir_all(&store);
}
Err(_) => {
eprintln!(
"{} YOUR UNSTAGED CHANGES COULD NOT BE PUT BACK AUTOMATICALLY.",
error_sign()
);
eprintln!(" They are safe, in: {}", store.display());
eprintln!(" Recover them with: amont restore");
}
}
}
}
fn has_contents(dir: &Path) -> bool {
std::fs::read_dir(dir)
.map(|mut entries| entries.next().is_some())
.unwrap_or(false)
}
fn held_nothing(store: &Path) -> String {
let _ = std::fs::remove_dir_all(store);
format!(
"{} could not hold the unstaged changes aside; nothing was changed",
error_sign()
)
}
fn put_back(store: &Path, root: &Path) -> std::io::Result<()> {
match std::fs::read(store.join(INDEX)) {
Ok(raw) => {
let entries = parse_index(&raw).map_err(std::io::Error::other)?;
put_back_v1(store, root, &entries)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => put_back_legacy(store, root),
Err(e) => Err(e),
}
}
fn put_back_v1(store: &Path, root: &Path, entries: &[Held]) -> std::io::Result<()> {
let expected = read_expected(store);
let guard = !crate::hooks::common::fixing_requested();
let mut preserved: Vec<(String, std::path::PathBuf)> = Vec::new();
let escapes = |rel: &str| {
std::io::Error::other(format!(
"held store names a path outside the worktree: {rel:?}"
))
};
for e in entries {
match e {
Held::Absent { rel } => {
let relp = safe_rel(rel).ok_or_else(|| escapes(rel))?;
let _ = std::fs::remove_file(root.join(relp));
}
Held::Symlink { rel, target } => {
let relp = safe_rel(rel).ok_or_else(|| escapes(rel))?;
let link = root.join(relp);
let _ = std::fs::remove_file(&link);
create_symlink(target, &link)?;
}
Held::Modified { rel, mode } => {
let relp = safe_rel(rel).ok_or_else(|| escapes(rel))?;
let target = root.join(&relp);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
let held = std::fs::read(store.join(FILES).join(&relp))?;
if guard
&& expected.get(rel.as_str()).is_some_and(|want| {
std::fs::read(&target)
.is_ok_and(|now| content_hash(&now) != *want && now != held)
})
{
let kept = preserve(store, &relp, &held)?;
preserved.push((rel.clone(), kept));
continue;
}
std::fs::write(&target, held)?;
if let Some(m) = mode {
set_mode(&target, *m)?;
}
}
}
}
if !preserved.is_empty() {
eprintln!(
"{} {} file(s) changed while the checks ran — the newer content was KEPT.",
crate::ui::warning_sign(),
preserved.len()
);
eprintln!(" The unstaged version each held before the commit is parked at:");
for (rel, kept) in &preserved {
eprintln!(" {} -> {}", crate::ui::sanitize(rel), kept.display());
}
eprintln!(" Compare and merge by hand; the parked copies are yours to delete.");
}
Ok(())
}
fn preserve(store: &Path, relp: &Path, held: &[u8]) -> std::io::Result<std::path::PathBuf> {
let base = store
.parent()
.map(|p| p.join(PRESERVED))
.ok_or_else(|| std::io::Error::other("store has no parent"))?;
let mut to = base.join(relp);
let mut n = 0;
while to.exists() {
n += 1;
to = base.join(relp).with_extension(format!("kept-{n}"));
}
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&to, held)?;
Ok(to)
}
fn read_expected(store: &Path) -> std::collections::HashMap<String, u64> {
let Ok(raw) = std::fs::read_to_string(store.join(EXPECTED)) else {
return Default::default();
};
let mut map = std::collections::HashMap::new();
let mut it = raw.split('\0');
while let (Some(rel), Some(hex)) = (it.next(), it.next()) {
if rel.is_empty() {
break;
}
if let Ok(h) = u64::from_str_radix(hex, 16) {
map.insert(rel.to_string(), h);
}
}
map
}
fn put_back_legacy(store: &Path, root: &Path) -> std::io::Result<()> {
for entry in walk(store)? {
let rel = entry.strip_prefix(store).unwrap_or(&entry).to_path_buf();
let rel_str = rel.to_string_lossy().to_string();
let escapes = || {
std::io::Error::other(format!(
"held store names a path outside the worktree: {rel_str:?}"
))
};
if let Some(original) = rel_str.strip_suffix(".amont-absent") {
let relp = safe_rel(original).ok_or_else(escapes)?;
let _ = std::fs::remove_file(root.join(relp));
continue;
}
if let Some(original) = rel_str.strip_suffix(".amont-symlink") {
let link_target = std::fs::read_to_string(&entry)?;
let relp = safe_rel(original).ok_or_else(escapes)?;
let link_path = root.join(relp);
let _ = std::fs::remove_file(&link_path);
create_symlink(&link_target, &link_path)?;
continue;
}
let relp = safe_rel(&rel_str).ok_or_else(escapes)?;
let target = root.join(&relp);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&target, std::fs::read(&entry)?)?;
}
Ok(())
}
#[cfg(unix)]
fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
let resolved = link
.parent()
.map(|parent| parent.join(target))
.unwrap_or_else(|| Path::new(target).to_path_buf());
if resolved.is_dir() {
std::os::windows::fs::symlink_dir(target, link)
} else {
std::os::windows::fs::symlink_file(target, link)
}
}
#[cfg(not(any(unix, windows)))]
fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
Err(std::io::Error::other(format!(
"no symlink support on this platform: {} -> {target}",
link.display()
)))
}
fn walk(dir: &Path) -> std::io::Result<Vec<std::path::PathBuf>> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
out.extend(walk(&path)?);
} else {
out.push(path);
}
}
Ok(out)
}
fn store_dir() -> Option<std::path::PathBuf> {
let dir = crate::git::stdout(&["rev-parse", "--git-dir"])?;
Some(Path::new(&dir).join(STORE))
}
impl Drop for StagedOnly {
fn drop(&mut self) {
if self.held {
StagedOnly::restore();
}
}
}
pub fn restore_command() -> Result<(), String> {
let store = store_dir().ok_or_else(|| "not inside a git repository".to_string())?;
if !store.is_dir() {
println!("{} nothing of ours to restore", warning_sign());
return Ok(());
}
let root = crate::hooks::common::repo_root_checked()?;
put_back(&store, Path::new(&root))
.map_err(|e| format!("could not put {} back: {e}", store.display()))?;
let _ = std::fs::remove_dir_all(&store);
println!("restored your unstaged changes");
Ok(())
}
#[cfg(unix)]
pub fn install_signal_handler() {
let mut fds = [-1i32; 2];
if unsafe { libc_pipe(fds.as_mut_ptr()) } != 0 {
return;
}
let (read_fd, write_fd) = (fds[0], fds[1]);
SIGNAL_PIPE_WRITE.store(write_fd, Ordering::SeqCst);
std::thread::spawn(move || loop {
let mut byte = 0u8;
let n = unsafe { libc_read(read_fd, &mut byte as *mut u8, 1) };
if n <= 0 {
return; }
StagedOnly::restore();
let sig = PENDING_SIGNAL.load(Ordering::SeqCst);
if sig != 0 {
unsafe {
libc_signal(sig, 0); libc_raise(sig);
}
}
});
extern "C" fn on_signal(sig: i32) {
PENDING_SIGNAL.store(sig, Ordering::SeqCst);
let fd = SIGNAL_PIPE_WRITE.load(Ordering::SeqCst);
if fd >= 0 {
let byte = 1u8;
unsafe {
libc_write(fd, &byte as *const u8, 1);
}
}
}
const SIGHUP: i32 = 1;
const SIGINT: i32 = 2;
const SIGQUIT: i32 = 3;
const SIGTERM: i32 = 15;
unsafe {
for sig in [SIGHUP, SIGINT, SIGQUIT, SIGTERM] {
libc_signal(sig, on_signal as *const () as usize);
}
}
}
#[cfg(not(unix))]
pub fn install_signal_handler() {}
#[cfg(unix)]
static SIGNAL_PIPE_WRITE: AtomicI32 = AtomicI32::new(-1);
#[cfg(unix)]
static PENDING_SIGNAL: AtomicI32 = AtomicI32::new(0);
#[cfg(unix)]
extern "C" {
#[link_name = "signal"]
fn libc_signal_raw(sig: i32, handler: usize) -> usize;
#[link_name = "raise"]
fn libc_raise_raw(sig: i32) -> i32;
#[link_name = "pipe"]
fn libc_pipe_raw(fds: *mut i32) -> i32;
#[link_name = "read"]
fn libc_read_raw(fd: i32, buf: *mut u8, count: usize) -> isize;
#[link_name = "write"]
fn libc_write_raw(fd: i32, buf: *const u8, count: usize) -> isize;
}
#[cfg(unix)]
unsafe fn libc_signal(sig: i32, handler: usize) {
unsafe {
libc_signal_raw(sig, handler);
}
}
#[cfg(unix)]
unsafe fn libc_raise(sig: i32) {
unsafe {
libc_raise_raw(sig);
}
}
#[cfg(unix)]
unsafe fn libc_pipe(fds: *mut i32) -> i32 {
unsafe { libc_pipe_raw(fds) }
}
#[cfg(unix)]
unsafe fn libc_read(fd: i32, buf: *mut u8, count: usize) -> isize {
unsafe { libc_read_raw(fd, buf, count) }
}
#[cfg(unix)]
unsafe fn libc_write(fd: i32, buf: *const u8, count: usize) -> isize {
unsafe { libc_write_raw(fd, buf, count) }
}
#[cfg(test)]
mod tests {
use super::*;
fn modified(rel: &str) -> Held {
Held::Modified {
rel: rel.to_string(),
mode: Some(0o100_644),
}
}
#[test]
fn the_index_round_trips_hostile_names() {
let entries = vec![
modified("a\nb.txt"),
modified("a\tb"),
modified("a\\b"),
modified("é.json"),
modified("quote\"and'apostrophe"),
modified("notes.amont-absent"),
Held::Absent {
rel: "gone.amont-symlink".to_string(),
},
Held::Symlink {
rel: "link\nname".to_string(),
target: "target\nwith\nnewlines".to_string(),
},
];
let raw = encode_index(&entries);
assert_eq!(parse_index(&raw).expect("round trip"), entries);
}
#[test]
fn an_empty_index_round_trips() {
let raw = encode_index(&[]);
assert_eq!(parse_index(&raw).expect("round trip"), Vec::<Held>::new());
}
#[test]
fn parse_index_rejects_a_foreign_header() {
let err = parse_index(b"amont-held-v99\0file\0a\0\0").expect_err("must refuse");
assert!(err.contains("amont-held-v1"), "{err}");
}
#[test]
fn parse_index_rejects_a_truncated_record() {
let raw = b"amont-held-v1\0file\0a.txt\0";
let err = parse_index(raw).expect_err("must refuse");
assert!(err.contains("ends mid-record"), "{err}");
}
#[test]
fn parse_index_rejects_an_unknown_record_kind() {
let raw = b"amont-held-v1\0execute\0rm -rf\0";
assert!(parse_index(raw).is_err());
}
#[test]
fn safe_rel_refuses_anything_that_leaves_the_tree() {
for bad in [
"",
"..",
"../x",
"a/../../b",
"/etc/passwd",
"/",
".",
"./a",
] {
assert!(safe_rel(bad).is_none(), "{bad:?} must be refused");
}
for good in ["a", "a/b.txt", "é.json", "a\nb", "notes.amont-absent"] {
assert!(safe_rel(good).is_some(), "{good:?} should be allowed");
}
}
#[cfg(windows)]
#[test]
fn safe_rel_refuses_a_drive_prefix() {
assert!(safe_rel("C:\\Windows\\System32").is_none());
assert!(safe_rel("C:x").is_none());
}
}