//! Reflog, moving a branch back, cleaning with a preview (TREE-13). use std::{io, path::Path}; use crate::git::Git; fn bad(what: &str) -> io::Error { io::Error::new( io::ErrorKind::InvalidData, format!("an unread record {what:?}"), ) } /// NUL-split records of `n` fields; a field may be empty. pub fn records(bytes: &[u8], n: usize) -> io::Result>> { let text = std::str::from_utf8(bytes).map_err(|_| bad("not UTF-8"))?; let mut fields: Vec<&str> = text.split('\0').collect(); if fields.last() == Some(&"") { fields.pop(); } if !fields.len().is_multiple_of(n) { return Err(bad(text)); } Ok(fields.chunks(n).map(<[&str]>::to_vec).collect()) } /// Where HEAD was; newest first. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Moved { pub oid: String, /// `HEAD@{3}`. pub name: String, /// What moved it, as Git wrote. pub said: String, pub at: i64, /// Its count from the oldest; new moves leave it be. pub seq: usize, } impl Moved { /// The same event, wherever it now sits. pub fn same(&self, other: &Moved) -> bool { (&self.oid, &self.said, self.at, self.seq) == (&other.oid, &other.said, other.at, other.seq) } } /// How many reflog entries the panel lists. const KEEP: usize = 200; pub fn reflog(git: &Git, root: &Path) -> io::Result> { if !git.born(root)? { return Ok(Vec::new()); } // With a date mode, `%gd` carries when HEAD moved. let format = "--format=%H%x00%gd%x00%gs"; let count = format!("-n{KEEP}"); let out = git.run_or_one( root, &[ "reflog", "show", "-z", "--date=unix", format, &count, "HEAD", "--", ], )?; // Every entry, to count each one's place from the oldest. let all = git.run_or_one(root, &["reflog", "show", "-z", "--format=%h", "HEAD", "--"])?; let total = records(&all, 1)?.len(); records(&out, 3)? .into_iter() .enumerate() .map(|(at, fields)| { let [oid, when, said] = fields[..] else { return Err(bad(&fields.join(" "))); }; let time = when .strip_prefix("HEAD@{") .and_then(|when| when.strip_suffix('}')) .and_then(|when| when.parse().ok()) .ok_or_else(|| bad(when))?; Ok(Moved { oid: oid.to_string(), name: format!("HEAD@{{{at}}}"), said: said.to_string(), at: time, seq: total .checked_sub(at + 1) .ok_or_else(|| bad("the reflog grew"))?, }) }) .collect() } /// Moving HEAD's branch to a commit: what leaves it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Move { /// `refs/heads/main`, or none when detached. pub branch: Option, pub head: String, pub to: String, /// Commits left behind: `abc1234 subject`. pub left: Vec, } fn words(bytes: Vec) -> String { String::from_utf8_lossy(&bytes).trim().to_string() } /// HEAD's branch ref, if any, and its commit. pub fn head_now(git: &Git, root: &Path) -> io::Result<(Option, String)> { let branch = words(git.run_or_one(root, &["symbolic-ref", "-q", "HEAD"])?); let head = words(git.run(root, &["rev-parse", "--verify", "HEAD"])?); Ok(((!branch.is_empty()).then_some(branch), head)) } /// `reset --keep` would drop staged work; refuse instead. pub fn nothing_staged(git: &Git, root: &Path) -> io::Result<()> { if git .run(root, &["diff", "--cached", "--name-only", "-z"])? .is_empty() { return Ok(()); } Err(io::Error::other( "staged changes would be lost; commit or unstage them first", )) } pub fn plan_move(git: &Git, root: &Path, to: &str) -> io::Result { nothing_staged(git, root)?; let (branch, head) = head_now(git, root)?; let range = format!("{to}..{head}"); let left = words(git.run(root, &["log", "--format=%h %s", &range, "--"])?); Ok(Move { branch, head, to: to.to_string(), left: left.lines().map(str::to_string).collect(), }) } /// Undoes Git's C quoting of a path. fn unquote(said: &str) -> io::Result { let Some(inner) = said.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else { return Ok(said.to_string()); }; let (mut out, mut chars) = (Vec::new(), inner.chars()); while let Some(c) = chars.next() { if c != '\\' { let mut buffer = [0; 4]; out.extend_from_slice(c.encode_utf8(&mut buffer).as_bytes()); continue; } let byte = match chars.next().ok_or_else(|| bad(said))? { 'a' => 7, 'b' => 8, 't' => b'\t', 'n' => b'\n', 'v' => 11, 'f' => 12, 'r' => b'\r', '"' => b'"', '\\' => b'\\', digit @ '0'..='3' => { let rest: String = chars.by_ref().take(2).collect(); u8::from_str_radix(&format!("{digit}{rest}"), 8).map_err(|_| bad(said))? } _ => return Err(bad(said)), }; out.push(byte); } String::from_utf8(out).map_err(|_| bad(said)) } /// The flags a clean runs with, preview and real. pub fn clean_flags(ignored: bool) -> Vec { let mut flags = vec!["-d".to_string()]; flags.extend(ignored.then(|| "-x".to_string())); flags } /// What a clean removes, fixed when shown. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Clean { /// Each file, folders opened up. pub files: Vec, /// Folders, deepest first; removed only once empty. pub dirs: Vec, } /// Folders under `dir`, skipping other repositories, deepest first. fn folders(root: &Path, dir: &str, out: &mut Vec) -> io::Result<()> { for entry in std::fs::read_dir(root.join(dir))? { let entry = entry?; let kind = entry.file_type()?; let name = entry .file_name() .into_string() .map_err(|_| bad("a folder name"))?; let inner = format!("{dir}{name}/"); if kind.is_dir() && !entry.path().join(".git").exists() { folders(root, &inner, out)?; } } out.push(dir.to_string()); Ok(()) } /// What `git clean` would remove, file by file. pub fn clean_preview(git: &Git, root: &Path, ignored: bool) -> io::Result { let mut args: Vec = ["-c", "core.quotePath=false", "clean", "-n"] .map(String::from) .into(); args.extend(clean_flags(ignored)); let args: Vec<&str> = args.iter().map(String::as_str).collect(); let out = String::from_utf8(git.run(root, &args)?).map_err(|_| bad("not UTF-8"))?; let mut clean = Clean::default(); for path in out .lines() .filter_map(|line| line.strip_prefix("Would remove ")) { let path = unquote(path)?; if !path.ends_with('/') { clean.files.push(path); continue; } let mut list = vec!["ls-files", "--others", "-z"]; if !ignored { list.push("--exclude-standard"); } list.extend(["--", &path]); let inside = git.run(root, &list)?; let inside = std::str::from_utf8(&inside).map_err(|_| bad("not UTF-8"))?; // Another repository inside stays, as clean leaves it. clean.files.extend( inside .split('\0') .filter(|file| !file.is_empty() && !file.ends_with('/')) .map(str::to_string), ); folders(root, &path, &mut clean.dirs)?; } Ok(clean) } #[cfg(test)] mod tests { #[test] fn quoted_paths_come_back_whole() { let read = |said| super::unquote(said).expect("reads"); assert_eq!(read("plain name.txt"), "plain name.txt"); assert_eq!(read(r#""tab\there.txt""#), "tab\there.txt"); assert_eq!(read(r#""say \"hi\"\\.txt""#), "say \"hi\"\\.txt"); assert_eq!(read(r#""\344\270\255.txt""#), "δΈ­.txt"); assert!(super::unquote(r#""bad\q""#).is_err()); } #[test] fn records_keep_empty_fields() { let read = super::records(b"a\0\0c\0d\0e\0f\0", 3).expect("reads"); assert_eq!(read, [vec!["a", "", "c"], vec!["d", "e", "f"]]); assert!(super::records(b"a\0b\0", 3).is_err()); } }