Files
ElyOffice/apps/ely-tree/src/recover.rs
T
ZacharyZhang-NYandClaude Opus 5.5 9c9f9ab8f9 Zero warnings on Windows and Linux, clippy included (W-02, L-02)
rustc and clippy are clean for the whole workspace, every target, on
both. clippy --fix moved test modules last and took the shorter
forms; long types got named aliases; ElyTree's form takes its title
and button words as one pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:36:06 -04:00

269 lines
8.4 KiB
Rust

//! 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<Vec<Vec<&str>>> {
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<Vec<Moved>> {
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<String>,
pub head: String,
pub to: String,
/// Commits left behind: `abc1234 subject`.
pub left: Vec<String>,
}
fn words(bytes: Vec<u8>) -> 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>, 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<Move> {
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<String> {
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<String> {
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<String>,
/// Folders, deepest first; removed only once empty.
pub dirs: Vec<String>,
}
/// Folders under `dir`, skipping other repositories, deepest first.
fn folders(root: &Path, dir: &str, out: &mut Vec<String>) -> 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<Clean> {
let mut args: Vec<String> = ["-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());
}
}