use anyhow::{Context, Result, bail};
use semver::Version;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
use crate::commit::Commit;
use crate::error::ReleaseError;
fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
format!("{:x}", hasher.finalize())
}
#[derive(Debug, Clone)]
pub struct TagInfo {
pub name: String,
pub version: Version,
pub sha: String,
}
pub trait GitRepository: Send + Sync {
fn latest_tag(&self, prefix: &str) -> Result<Option<TagInfo>, ReleaseError>;
fn commits_since(&self, from: Option<&str>) -> Result<Vec<Commit>, ReleaseError>;
fn create_tag(&self, name: &str, message: &str, sign: bool) -> Result<(), ReleaseError>;
fn push_tag(&self, name: &str) -> Result<(), ReleaseError>;
fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError>;
fn push(&self) -> Result<(), ReleaseError>;
fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError>;
fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError>;
fn all_tags(&self, prefix: &str) -> Result<Vec<TagInfo>, ReleaseError>;
fn commits_between(&self, from: Option<&str>, to: &str) -> Result<Vec<Commit>, ReleaseError>;
fn tag_date(&self, tag_name: &str) -> Result<String, ReleaseError>;
fn force_create_tag(&self, name: &str) -> Result<(), ReleaseError>;
fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError>;
fn head_sha(&self) -> Result<String, ReleaseError>;
fn commits_since_in_path(
&self,
from: Option<&str>,
path: &str,
) -> Result<Vec<Commit>, ReleaseError> {
let _ = path;
self.commits_since(from)
}
fn commits_between_in_path(
&self,
from: Option<&str>,
to: &str,
path: &str,
) -> Result<Vec<Commit>, ReleaseError> {
let _ = path;
self.commits_between(from, to)
}
}
fn git_unquote(s: &str) -> String {
let s = s.trim();
if !(s.starts_with('"') && s.ends_with('"')) {
return s.to_string();
}
let inner = &s[1..s.len() - 1];
let mut out = Vec::new();
let bytes = inner.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
i += 1;
match bytes[i] {
b'\\' => out.push(b'\\'),
b'"' => out.push(b'"'),
b'n' => out.push(b'\n'),
b't' => out.push(b'\t'),
b'r' => out.push(b'\r'),
b'a' => out.push(0x07),
b'b' => out.push(0x08),
b'f' => out.push(0x0C),
b'v' => out.push(0x0B),
b'0'..=b'3' => {
let mut val = (bytes[i] - b'0') as u16;
for _ in 0..2 {
if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
i += 1;
val = val * 8 + (bytes[i] - b'0') as u16;
} else {
break;
}
}
out.push(val as u8);
}
other => {
out.push(b'\\');
out.push(other);
}
}
} else {
out.push(bytes[i]);
}
i += 1;
}
String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).to_string())
}
pub struct GitRepo {
root: PathBuf,
}
#[allow(dead_code)]
impl GitRepo {
pub fn discover() -> Result<Self> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.context("failed to run git")?;
if !output.status.success() {
bail!("not in a git repository");
}
let root = String::from_utf8(output.stdout)
.context("invalid utf-8 from git")?
.trim()
.into();
Ok(Self { root })
}
pub fn root(&self) -> &PathBuf {
&self.root
}
fn git(&self, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(["-C", self.root.to_str().unwrap()])
.args(args)
.output()
.with_context(|| format!("failed to run git {}", args.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git {} failed: {}", args.join(" "), stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn git_allow_failure(&self, args: &[&str]) -> Result<(bool, String)> {
let output = Command::new("git")
.args(["-C", self.root.to_str().unwrap()])
.args(args)
.output()
.with_context(|| format!("failed to run git {}", args.join(" ")))?;
Ok((
output.status.success(),
String::from_utf8_lossy(&output.stdout).to_string(),
))
}
pub fn has_staged_changes(&self) -> Result<bool> {
let out = self.git(&["diff", "--cached", "--name-only"])?;
Ok(!out.trim().is_empty())
}
pub fn has_any_changes(&self) -> Result<bool> {
let out = self.git(&["status", "--porcelain"])?;
Ok(!out.trim().is_empty())
}
pub fn has_head(&self) -> Result<bool> {
let (ok, _) = self.git_allow_failure(&["rev-parse", "HEAD"])?;
Ok(ok)
}
pub fn reset_head(&self) -> Result<()> {
if self.has_head()? {
self.git(&["reset", "HEAD", "--quiet"])?;
} else {
let _ = self.git_allow_failure(&["rm", "--cached", "-r", ".", "--quiet"]);
}
Ok(())
}
pub fn stage_file(&self, file: &str) -> Result<bool> {
let (ok, _) = self.git_allow_failure(&["add", "--", file])?;
Ok(ok)
}
pub fn has_staged_after_add(&self) -> Result<bool> {
self.has_staged_changes()
}
pub fn commit(&self, message: &str) -> Result<()> {
let output = Command::new("git")
.args(["-C", self.root.to_str().unwrap()])
.args(["commit", "-F", "-"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("failed to spawn git commit")?;
use std::io::Write;
let mut child = output;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(message.as_bytes())?;
}
let out = child.wait_with_output()?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
bail!("git commit failed: {}", stderr.trim());
}
Ok(())
}
pub fn recent_commits(&self, count: usize) -> Result<String> {
self.git(&["--no-pager", "log", "--oneline", &format!("-{count}")])
}
pub fn diff_cached(&self) -> Result<String> {
self.git(&["diff", "--cached"])
}
pub fn diff_cached_stat(&self) -> Result<String> {
self.git(&["diff", "--cached", "--stat"])
}
pub fn diff_head(&self) -> Result<String> {
let (ok, out) = self.git_allow_failure(&["diff", "HEAD"])?;
if ok { Ok(out) } else { self.git(&["diff"]) }
}
pub fn diff_unified(&self, staged: bool, context: usize, files: &[String]) -> Result<String> {
let ctx_flag = format!("-U{context}");
let mut args: Vec<&str> = vec!["diff", &ctx_flag];
if staged {
args.push("--cached");
} else {
args.push("HEAD");
}
if !files.is_empty() {
args.push("--");
for f in files {
args.push(f.as_str());
}
}
let (ok, out) = self.git_allow_failure(&args)?;
if ok {
Ok(out)
} else if !staged && files.is_empty() {
self.git(&["diff", &ctx_flag])
} else {
Ok(out)
}
}
pub fn diff_numstat(
&self,
staged: bool,
files: &[String],
) -> Result<Vec<(usize, usize, String)>> {
let mut args: Vec<&str> = vec!["diff", "--numstat"];
if staged {
args.push("--cached");
} else {
args.push("HEAD");
}
if !files.is_empty() {
args.push("--");
for f in files {
args.push(f.as_str());
}
}
let (ok, out) = self.git_allow_failure(&args)?;
if !ok && !staged && files.is_empty() {
let out = self.git(&["diff", "--numstat"])?;
return Self::parse_numstat(&out);
}
Self::parse_numstat(&out)
}
fn parse_numstat(out: &str) -> Result<Vec<(usize, usize, String)>> {
let mut result = Vec::new();
for line in out.lines() {
let parts: Vec<&str> = line.splitn(3, '\t').collect();
if parts.len() == 3 {
let add = parts[0].parse().unwrap_or(0);
let del = parts[1].parse().unwrap_or(0);
let path = if let Some(pos) = parts[2].find(" => ") {
git_unquote(&parts[2][pos + 4..])
} else {
git_unquote(parts[2])
};
result.push((add, del, path));
}
}
Ok(result)
}
pub fn status_porcelain(&self) -> Result<String> {
self.git(&["status", "--porcelain"])
}
pub fn untracked_files(&self) -> Result<String> {
self.git(&["ls-files", "--others", "--exclude-standard"])
}
pub fn show(&self, rev: &str) -> Result<String> {
self.git(&["show", rev])
}
pub fn log_range(&self, base: &str, count: Option<usize>) -> Result<String> {
let mut args = vec!["--no-pager", "log", "--oneline"];
let count_str;
if let Some(n) = count {
count_str = format!("-{n}");
args.push(&count_str);
}
args.push(base);
self.git(&args)
}
pub fn diff_range(&self, base: &str) -> Result<String> {
self.git(&["diff", base])
}
pub fn current_branch(&self) -> Result<String> {
let out = self.git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
Ok(out.trim().to_string())
}
pub fn head_short(&self) -> Result<String> {
let out = self.git(&["rev-parse", "--short", "HEAD"])?;
Ok(out.trim().to_string())
}
pub fn commits_since_last_tag(&self) -> Result<usize> {
let (ok, tag) = self.git_allow_failure(&["describe", "--tags", "--abbrev=0"])?;
let tag = tag.trim();
let out = if ok && !tag.is_empty() {
self.git(&["rev-list", &format!("{tag}..HEAD"), "--count"])?
} else {
self.git(&["rev-list", "HEAD", "--count"])?
};
out.trim()
.parse::<usize>()
.context("failed to parse commit count")
}
pub fn log_detailed(&self, count: usize) -> Result<String> {
let out = self.git(&[
"--no-pager",
"log",
"--reverse",
&format!("-{count}"),
"--format=%h %s%n%b%n---",
])?;
Ok(out)
}
pub fn file_statuses(&self) -> Result<HashMap<String, char>> {
let out = self.git(&["status", "--porcelain"])?;
let mut map = HashMap::new();
for line in out.lines() {
if line.len() < 3 {
continue;
}
let xy = &line.as_bytes()[..2];
let path = line[3..].to_string();
let (x, y) = (xy[0], xy[1]);
let is_rename = matches!((x, y), (b'R', _) | (_, b'R'));
if is_rename {
if let Some(pos) = path.find(" -> ") {
let old_path = git_unquote(&path[..pos]);
let new_path = git_unquote(&path[pos + 4..]);
map.insert(old_path, 'D');
map.insert(new_path, 'R');
} else {
map.insert(git_unquote(&path), 'R');
}
} else {
let status = match (x, y) {
(b'?', b'?') => 'A',
(b'A', _) | (_, b'A') => 'A',
(b'D', _) | (_, b'D') => 'D',
(b'M', _) | (_, b'M') | (b'T', _) | (_, b'T') => 'M',
_ => '~',
};
map.insert(git_unquote(&path), status);
}
}
Ok(map)
}
pub fn snapshot_working_tree(&self) -> Result<PathBuf> {
let snapshot_dir = snapshot_dir_for(&self.root)
.context("failed to resolve snapshot directory (no data directory available)")?;
if snapshot_dir.exists() {
std::fs::remove_dir_all(&snapshot_dir).ok();
}
std::fs::create_dir_all(&snapshot_dir).context("failed to create snapshot directory")?;
let files_dir = snapshot_dir.join("files");
std::fs::create_dir_all(&files_dir)?;
std::fs::write(
snapshot_dir.join("repo_root"),
self.root.to_string_lossy().as_bytes(),
)
.context("failed to write repo_root")?;
let (has_head, head_ref) = self.git_allow_failure(&["rev-parse", "HEAD"])?;
if has_head {
std::fs::write(snapshot_dir.join("head_ref"), head_ref.trim())
.context("failed to write head_ref")?;
}
let porcelain = self.git(&["status", "--porcelain"])?;
let staged_names = self.git(&["diff", "--cached", "--name-only", "-z"])?;
let staged_set: std::collections::HashSet<String> = staged_names
.split('\0')
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
#[derive(serde::Serialize, serde::Deserialize)]
struct ManifestEntry {
path: String,
index_status: char,
worktree_status: char,
staged: bool,
has_content: bool,
}
let mut manifest: Vec<ManifestEntry> = Vec::new();
for line in porcelain.lines() {
if line.len() < 3 {
continue;
}
let bytes = line.as_bytes();
let x = bytes[0] as char;
let y = bytes[1] as char;
let raw = line[3..].to_string();
let path = if let Some(pos) = raw.find(" -> ") {
git_unquote(&raw[pos + 4..])
} else {
git_unquote(&raw)
};
let src = self.root.join(&path);
let has_content = src.exists() && src.is_file();
if has_content {
let dest = files_dir.join(&path);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).ok();
}
if let Err(e) = std::fs::copy(&src, &dest) {
eprintln!("warning: failed to snapshot {path}: {e}");
}
}
manifest.push(ManifestEntry {
staged: staged_set.contains(path.as_str()),
path,
index_status: x,
worktree_status: y,
has_content,
});
}
let manifest_json =
serde_json::to_string_pretty(&manifest).context("failed to serialize manifest")?;
std::fs::write(snapshot_dir.join("manifest.json"), manifest_json)
.context("failed to write manifest.json")?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
std::fs::write(snapshot_dir.join("timestamp"), now.to_string())
.context("failed to write timestamp")?;
Ok(snapshot_dir)
}
pub fn restore_snapshot(&self) -> Result<()> {
let snapshot_dir = self.snapshot_dir()?;
if !snapshot_dir.join("timestamp").exists() {
bail!("no valid snapshot found");
}
let files_dir = snapshot_dir.join("files");
let head_ref_path = snapshot_dir.join("head_ref");
if head_ref_path.exists() {
let original_head = std::fs::read_to_string(&head_ref_path)?;
let original_head = original_head.trim();
if !original_head.is_empty() {
let _ = self.git_allow_failure(&["reset", "--soft", original_head]);
}
}
self.reset_head()?;
let manifest_path = snapshot_dir.join("manifest.json");
if !manifest_path.exists() {
bail!("snapshot manifest.json missing — cannot restore");
}
#[derive(serde::Deserialize)]
struct ManifestEntry {
path: String,
index_status: char,
worktree_status: char,
staged: bool,
has_content: bool,
}
let manifest_data = std::fs::read_to_string(&manifest_path)?;
let manifest: Vec<ManifestEntry> =
serde_json::from_str(&manifest_data).context("failed to parse snapshot manifest")?;
let mut restored = 0usize;
let mut failed = 0usize;
for entry in &manifest {
let dest = self.root.join(&entry.path);
if entry.has_content {
let src = files_dir.join(&entry.path);
if src.exists() {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).ok();
}
match std::fs::copy(&src, &dest) {
Ok(_) => restored += 1,
Err(e) => {
eprintln!("warning: failed to restore {}: {e}", entry.path);
failed += 1;
}
}
} else {
eprintln!("warning: snapshot missing content for {}", entry.path);
failed += 1;
}
} else if entry.index_status == 'D' || entry.worktree_status == 'D' {
if dest.exists() {
std::fs::remove_file(&dest).ok();
}
}
if entry.staged {
let _ = self.git_allow_failure(&["add", "--", &entry.path]);
}
}
if failed > 0 {
eprintln!("sr: restored {restored} files, {failed} failed");
}
Ok(())
}
pub fn clear_snapshot(&self) {
if let Ok(dir) = self.snapshot_dir() {
let _ = std::fs::remove_dir_all(&dir);
}
}
pub fn snapshot_dir(&self) -> Result<PathBuf> {
snapshot_dir_for(&self.root)
.context("failed to resolve snapshot directory (no data directory available)")
}
pub fn has_snapshot(&self) -> bool {
self.snapshot_dir()
.map(|d| d.join("timestamp").exists())
.unwrap_or(false)
}
}
fn snapshot_dir_for(repo_root: &std::path::Path) -> Option<PathBuf> {
let base = dirs::data_local_dir()?;
let repo_id = &sha256_hex(repo_root.to_string_lossy().as_bytes())[..16];
Some(base.join("sr").join("snapshots").join(repo_id))
}
pub struct SnapshotGuard<'a> {
repo: &'a GitRepo,
succeeded: bool,
}
impl<'a> SnapshotGuard<'a> {
pub fn new(repo: &'a GitRepo) -> Result<Self> {
repo.snapshot_working_tree()?;
Ok(Self {
repo,
succeeded: false,
})
}
pub fn success(mut self) {
self.succeeded = true;
self.repo.clear_snapshot();
}
}
impl Drop for SnapshotGuard<'_> {
fn drop(&mut self) {
if !self.succeeded && self.repo.has_snapshot() {
eprintln!("sr: operation failed, restoring working tree from snapshot...");
if let Err(e) = self.repo.restore_snapshot() {
eprintln!("sr: warning: snapshot restore failed: {e}");
if let Ok(dir) = self.repo.snapshot_dir() {
eprintln!(
"sr: snapshot preserved at {} for manual recovery",
dir.display()
);
}
} else {
self.repo.clear_snapshot();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_repo() -> (tempfile::TempDir, GitRepo) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let git = |args: &[&str]| {
Command::new("git")
.args(["-C", root.to_str().unwrap()])
.args(args)
.output()
.unwrap()
};
git(&["init"]);
git(&["config", "user.email", "test@test.com"]);
git(&["config", "user.name", "Test"]);
fs::write(root.join("init.txt"), "init").unwrap();
git(&["add", "init.txt"]);
git(&["commit", "-m", "initial"]);
let repo = GitRepo { root };
(dir, repo)
}
#[test]
fn snapshot_creates_manifest_with_staged_files() {
let (_dir, repo) = temp_repo();
fs::write(repo.root.join("new.go"), "package main").unwrap();
repo.git(&["add", "new.go"]).unwrap();
let snap_dir = repo.snapshot_working_tree().unwrap();
let manifest_path = snap_dir.join("manifest.json");
assert!(manifest_path.exists(), "manifest.json should exist");
let data = fs::read_to_string(&manifest_path).unwrap();
assert!(data.contains("new.go"), "manifest should list new.go");
assert!(
data.contains("\"staged\": true"),
"new.go should be marked staged"
);
assert!(
snap_dir.join("files/new.go").exists(),
"file content should be copied"
);
assert_eq!(
fs::read_to_string(snap_dir.join("files/new.go")).unwrap(),
"package main"
);
assert!(snap_dir.join("head_ref").exists());
repo.clear_snapshot();
}
#[test]
fn snapshot_restore_recovers_staged_new_files() {
let (_dir, repo) = temp_repo();
fs::write(repo.root.join("a.go"), "package a").unwrap();
fs::write(repo.root.join("b.go"), "package b").unwrap();
repo.git(&["add", "a.go", "b.go"]).unwrap();
repo.snapshot_working_tree().unwrap();
repo.reset_head().unwrap();
repo.git(&["add", "a.go"]).unwrap();
repo.git(&["commit", "-m", "partial"]).unwrap();
repo.restore_snapshot().unwrap();
assert!(repo.root.join("a.go").exists());
assert!(repo.root.join("b.go").exists());
assert_eq!(
fs::read_to_string(repo.root.join("a.go")).unwrap(),
"package a"
);
assert_eq!(
fs::read_to_string(repo.root.join("b.go")).unwrap(),
"package b"
);
let staged = repo.git(&["diff", "--cached", "--name-only"]).unwrap();
assert!(staged.contains("a.go"), "a.go should be re-staged");
assert!(staged.contains("b.go"), "b.go should be re-staged");
let log = repo.git(&["log", "--oneline"]).unwrap();
assert!(
!log.contains("partial"),
"partial commit should be undone by HEAD reset"
);
repo.clear_snapshot();
}
#[test]
fn snapshot_restore_with_dirty_index_does_not_conflict() {
let (_dir, repo) = temp_repo();
fs::write(repo.root.join("file.rs"), "fn main() {}").unwrap();
repo.git(&["add", "file.rs"]).unwrap();
repo.snapshot_working_tree().unwrap();
repo.reset_head().unwrap();
repo.git(&["add", "file.rs"]).unwrap();
let result = repo.restore_snapshot();
assert!(
result.is_ok(),
"restore should succeed with dirty index: {result:?}"
);
assert_eq!(
fs::read_to_string(repo.root.join("file.rs")).unwrap(),
"fn main() {}"
);
repo.clear_snapshot();
}
#[test]
fn snapshot_handles_modified_files() {
let (_dir, repo) = temp_repo();
fs::write(repo.root.join("init.txt"), "modified content").unwrap();
repo.git(&["add", "init.txt"]).unwrap();
repo.snapshot_working_tree().unwrap();
repo.reset_head().unwrap();
fs::write(repo.root.join("init.txt"), "wrong content").unwrap();
repo.restore_snapshot().unwrap();
assert_eq!(
fs::read_to_string(repo.root.join("init.txt")).unwrap(),
"modified content"
);
repo.clear_snapshot();
}
#[test]
fn snapshot_guard_restores_on_drop() {
let (_dir, repo) = temp_repo();
fs::write(repo.root.join("guarded.txt"), "important").unwrap();
repo.git(&["add", "guarded.txt"]).unwrap();
{
let _guard = SnapshotGuard::new(&repo).unwrap();
repo.reset_head().unwrap();
fs::remove_file(repo.root.join("guarded.txt")).ok();
}
assert!(repo.root.join("guarded.txt").exists());
assert_eq!(
fs::read_to_string(repo.root.join("guarded.txt")).unwrap(),
"important"
);
}
#[test]
fn snapshot_guard_clears_on_success() {
let (_dir, repo) = temp_repo();
fs::write(repo.root.join("ok.txt"), "data").unwrap();
repo.git(&["add", "ok.txt"]).unwrap();
let guard = SnapshotGuard::new(&repo).unwrap();
assert!(repo.has_snapshot());
guard.success();
assert!(!repo.has_snapshot());
}
#[test]
fn file_statuses_includes_both_sides_of_rename() {
let (_dir, repo) = temp_repo();
fs::write(repo.root.join("old_name.txt"), "content").unwrap();
repo.git(&["add", "old_name.txt"]).unwrap();
repo.git(&["commit", "-m", "add old_name"]).unwrap();
repo.git(&["mv", "old_name.txt", "new_name.txt"]).unwrap();
let statuses = repo.file_statuses().unwrap();
assert_eq!(
statuses.get("old_name.txt").copied(),
Some('D'),
"old path should appear as deleted"
);
assert_eq!(
statuses.get("new_name.txt").copied(),
Some('R'),
"new path should appear as renamed"
);
}
#[test]
fn stage_file_handles_many_moves_and_deletes_after_reset() {
let (_dir, repo) = temp_repo();
for i in 0..30 {
fs::write(
repo.root.join(format!("file_{i}.txt")),
format!("content {i}"),
)
.unwrap();
}
repo.git(&["add", "."]).unwrap();
repo.git(&["commit", "-m", "add files"]).unwrap();
fs::create_dir_all(repo.root.join("moved")).unwrap();
for i in 0..10 {
repo.git(&[
"mv",
&format!("file_{i}.txt"),
&format!("moved/file_{i}.txt"),
])
.unwrap();
}
for i in 10..20 {
repo.git(&["rm", &format!("file_{i}.txt")]).unwrap();
}
for i in 20..30 {
fs::write(
repo.root.join(format!("file_{i}.txt")),
format!("modified {i}"),
)
.unwrap();
repo.git(&["add", &format!("file_{i}.txt")]).unwrap();
}
for i in 30..35 {
fs::write(repo.root.join(format!("new_{i}.txt")), format!("new {i}")).unwrap();
repo.git(&["add", &format!("new_{i}.txt")]).unwrap();
}
let statuses = repo.file_statuses().unwrap();
assert!(
statuses.len() >= 30,
"should have many file statuses, got {}",
statuses.len()
);
repo.reset_head().unwrap();
let mut failed = Vec::new();
for (file, status) in &statuses {
if file == "init.txt" {
continue;
}
let ok = repo.stage_file(file).unwrap();
if !ok {
failed.push((file.clone(), *status));
}
}
assert!(
failed.is_empty(),
"stage_file failed for {} files: {:?}",
failed.len(),
failed
);
}
#[test]
fn stage_file_handles_manual_moves_after_reset() {
let (_dir, repo) = temp_repo();
fs::create_dir_all(repo.root.join("old_dir")).unwrap();
for i in 0..10 {
fs::write(
repo.root.join(format!("old_dir/file_{i}.txt")),
format!("content {i}"),
)
.unwrap();
}
repo.git(&["add", "."]).unwrap();
repo.git(&["commit", "-m", "add directory"]).unwrap();
fs::rename(repo.root.join("old_dir"), repo.root.join("new_dir")).unwrap();
repo.git(&["add", "-A"]).unwrap();
let statuses = repo.file_statuses().unwrap();
repo.reset_head().unwrap();
let mut failed = Vec::new();
for (file, status) in &statuses {
if file == "init.txt" {
continue;
}
let ok = repo.stage_file(file).unwrap();
if !ok {
failed.push((file.clone(), *status));
}
}
assert!(
failed.is_empty(),
"stage_file failed for {} files after manual move: {:?}",
failed.len(),
failed
);
}
#[test]
fn stage_file_handles_new_files_mixed_with_moves() {
let (_dir, repo) = temp_repo();
for i in 0..5 {
fs::write(
repo.root.join(format!("existing_{i}.txt")),
format!("existing {i}"),
)
.unwrap();
}
repo.git(&["add", "."]).unwrap();
repo.git(&["commit", "-m", "add existing files"]).unwrap();
fs::create_dir_all(repo.root.join("moved")).unwrap();
for i in 0..3 {
repo.git(&[
"mv",
&format!("existing_{i}.txt"),
&format!("moved/existing_{i}.txt"),
])
.unwrap();
}
repo.git(&["rm", "existing_3.txt"]).unwrap();
for i in 0..5 {
fs::write(
repo.root.join(format!("brand_new_{i}.txt")),
format!("new {i}"),
)
.unwrap();
}
repo.git(&["add", "."]).unwrap();
let statuses = repo.file_statuses().unwrap();
repo.reset_head().unwrap();
let mut failed = Vec::new();
for (file, status) in &statuses {
if file == "init.txt" {
continue;
}
let ok = repo.stage_file(file).unwrap();
if !ok {
failed.push((file.clone(), *status));
}
}
assert!(
failed.is_empty(),
"stage_file failed for {} files: {:?}",
failed.len(),
failed
);
}
#[test]
fn stage_file_handles_quoted_paths_from_moves() {
let (_dir, repo) = temp_repo();
fs::write(repo.root.join("old name.txt"), "content").unwrap();
repo.git(&["add", "."]).unwrap();
repo.git(&["commit", "-m", "add file with spaces"]).unwrap();
repo.git(&["mv", "old name.txt", "new name.txt"]).unwrap();
let statuses = repo.file_statuses().unwrap();
assert!(
statuses.contains_key("old name.txt"),
"old path should be unquoted; got keys: {:?}",
statuses.keys().collect::<Vec<_>>()
);
assert!(
statuses.contains_key("new name.txt"),
"new path should be unquoted; got keys: {:?}",
statuses.keys().collect::<Vec<_>>()
);
repo.reset_head().unwrap();
let old_ok = repo.stage_file("old name.txt").unwrap();
assert!(old_ok, "stage_file should succeed for old (deleted) path");
let new_ok = repo.stage_file("new name.txt").unwrap();
assert!(new_ok, "stage_file should succeed for new (added) path");
}
#[test]
fn file_statuses_unquotes_paths_with_special_chars() {
let (_dir, repo) = temp_repo();
fs::write(repo.root.join("my file.txt"), "content").unwrap();
fs::write(repo.root.join("to delete.txt"), "delete me").unwrap();
repo.git(&["add", "."]).unwrap();
repo.git(&["commit", "-m", "add spaced files"]).unwrap();
fs::write(repo.root.join("my file.txt"), "modified").unwrap();
repo.git(&["rm", "to delete.txt"]).unwrap();
fs::write(repo.root.join("brand new file.txt"), "new").unwrap();
repo.git(&["add", "."]).unwrap();
let statuses = repo.file_statuses().unwrap();
assert!(
statuses.contains_key("my file.txt"),
"modified file should be unquoted; keys: {:?}",
statuses.keys().collect::<Vec<_>>()
);
assert!(
statuses.contains_key("to delete.txt"),
"deleted file should be unquoted; keys: {:?}",
statuses.keys().collect::<Vec<_>>()
);
assert!(
statuses.contains_key("brand new file.txt"),
"new file should be unquoted; keys: {:?}",
statuses.keys().collect::<Vec<_>>()
);
}
#[test]
fn stage_file_works_across_sequential_commits_with_moves() {
let (_dir, repo) = temp_repo();
for i in 0..10 {
fs::write(
repo.root.join(format!("src_{i}.txt")),
format!("content {i}"),
)
.unwrap();
}
repo.git(&["add", "."]).unwrap();
repo.git(&["commit", "-m", "add source files"]).unwrap();
fs::create_dir_all(repo.root.join("dst")).unwrap();
for i in 0..10 {
repo.git(&["mv", &format!("src_{i}.txt"), &format!("dst/src_{i}.txt")])
.unwrap();
}
let statuses = repo.file_statuses().unwrap();
repo.reset_head().unwrap();
for i in 0..10 {
let file = format!("dst/src_{i}.txt");
let ok = repo.stage_file(&file).unwrap();
assert!(ok, "should stage new path {file}");
}
repo.commit("feat: add new paths").unwrap();
let mut failed = Vec::new();
for i in 0..10 {
let file = format!("src_{i}.txt");
if let Some(&status) = statuses.get(&file) {
let ok = repo.stage_file(&file).unwrap();
if !ok {
failed.push((file, status));
}
}
}
assert!(
failed.is_empty(),
"stage_file failed for old paths after prior commit: {:?}",
failed
);
}
}