use anyhow::Context;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tracing::warn;
use crate::tools::shell::{internal_env_pairs, program_command};
use crate::util::unquote_c_style;
#[derive(Debug, Clone)]
pub struct CommitInfo {
pub hash: String,
pub lines_added: i64,
pub lines_removed: i64,
}
impl CommitInfo {
#[must_use]
pub fn short_hash(&self) -> &str {
self.hash.get(..7).unwrap_or(&self.hash)
}
}
pub(crate) const MAX_UNTRACKED_SIZE: u64 = 1024 * 1024;
const MAX_UNTRACKED_FILES_COUNTED: usize = 500;
const MAX_UNTRACKED_BYTES_READ: u64 = 10 * 1024 * 1024;
pub(crate) enum UntrackedFileRead {
Text(String),
TooLarge(u64),
Binary,
Skip,
}
fn classify_untracked_content(content: Vec<u8>) -> Option<String> {
if content.contains(&0) {
return None;
}
String::from_utf8(content).ok()
}
async fn read_untracked_file_with_meta(path: &Path, meta: &std::fs::Metadata) -> UntrackedFileRead {
if !meta.is_file() {
return UntrackedFileRead::Skip;
}
if meta.len() > MAX_UNTRACKED_SIZE {
return UntrackedFileRead::TooLarge(meta.len());
}
let Ok(content) = tokio::fs::read(path).await else {
return UntrackedFileRead::Skip;
};
classify_untracked_content(content).map_or(UntrackedFileRead::Binary, UntrackedFileRead::Text)
}
pub(crate) async fn read_untracked_file(path: &Path) -> UntrackedFileRead {
let Ok(meta) = tokio::fs::metadata(path).await else {
return UntrackedFileRead::Skip;
};
read_untracked_file_with_meta(path, &meta).await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiscardTarget {
File,
Directory,
}
#[must_use]
pub fn is_git_repo(path: &Path) -> bool {
path.join(".git").exists()
}
pub(crate) async fn run_git_show_diff(repo_path: &Path, hash: &str) -> anyhow::Result<String> {
run_git_command(
repo_path,
&[
"show",
"-m",
hash,
"--no-color",
"--find-renames",
"--format=",
],
)
.await
}
pub(crate) async fn run_git_worktree_diff(
repo_path: &Path,
unborn_head: bool,
) -> anyhow::Result<String> {
let base = if unborn_head {
run_git_empty_tree(repo_path).await?
} else {
"HEAD".to_string()
};
run_git_command(repo_path, &["diff", &base, "--no-color", "--find-renames"]).await
}
pub async fn run_git_status(repo_path: &Path) -> anyhow::Result<String> {
run_git_command(
repo_path,
&["status", "--porcelain", "--untracked-files=normal"],
)
.await
}
async fn run_git_status_all(repo_path: &Path) -> anyhow::Result<String> {
run_git_command(
repo_path,
&["status", "--porcelain", "--untracked-files=all"],
)
.await
}
pub async fn run_git_show(
repo_path: &Path,
file_path: &str,
commit_ref: Option<&str>,
) -> Option<String> {
let show_arg = if let Some(hash) = commit_ref {
format!("{hash}:{file_path}")
} else {
format!("HEAD:{file_path}")
};
run_git_command(repo_path, &["show", &show_arg]).await.ok()
}
pub(crate) fn resolve_git_top_level(repo_path: &Path) -> Option<PathBuf> {
let dir = repo_path.ancestors().find(|dir| is_git_repo(dir))?;
Some(crate::util::strip_verbatim_prefix(
&std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()),
))
}
fn inject_safe_directory(cmd: &mut std::process::Command, repo_path: &Path) {
let Some(top_level) = resolve_git_top_level(repo_path) else {
return;
};
cmd.env("GIT_CONFIG_COUNT", "1");
cmd.env("GIT_CONFIG_KEY_0", "safe.directory");
cmd.env("GIT_CONFIG_VALUE_0", top_level.as_path());
}
fn git_std_command(repo_root: Option<&Path>) -> std::process::Command {
let mut cmd = program_command("git", &internal_env_pairs(), None);
cmd.env("LC_ALL", "C");
if let Some(path) = repo_root {
inject_safe_directory(&mut cmd, path);
}
cmd
}
fn git_command(repo_root: Option<&Path>) -> tokio::process::Command {
git_std_command(repo_root).into()
}
pub(crate) fn tracked_paths(repo: &Path) -> Option<Vec<String>> {
let mut cmd = git_std_command(Some(repo));
cmd.current_dir(repo)
.args(["-c", "core.fsmonitor=false", "ls-files", "-z", "--cached"])
.env("GIT_OPTIONAL_LOCKS", "0")
.stdin(Stdio::null())
.stderr(Stdio::null());
let output = cmd.output().ok()?;
if !output.status.success() {
return None;
}
Some(
output
.stdout
.split(|&byte| byte == 0)
.filter_map(|path| std::str::from_utf8(path).ok())
.filter(|path| !path.is_empty())
.map(str::to_string)
.collect(),
)
}
pub(crate) async fn run_git_output(
repo_path: &Path,
args: &[&str],
) -> anyhow::Result<std::process::Output> {
run_git_output_env(repo_path, &[], args).await
}
async fn run_git_output_env(
repo_path: &Path,
env: &[(&str, &OsStr)],
args: &[&str],
) -> anyhow::Result<std::process::Output> {
let mut cmd = git_command(Some(repo_path));
for (name, value) in env {
cmd.env(name, value);
}
cmd.args(args).current_dir(repo_path);
cmd.stdin(Stdio::null());
cmd.output()
.await
.with_context(|| format!("Failed to run git {}", args.join(" ")))
}
pub async fn run_git_command(repo_path: &Path, args: &[&str]) -> anyhow::Result<String> {
run_git_command_env(repo_path, &[], args).await
}
async fn run_git_command_env(
repo_path: &Path,
env: &[(&str, &OsStr)],
args: &[&str],
) -> anyhow::Result<String> {
let output = run_git_output_env(repo_path, env, args).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git {} failed: {stderr}", args.join(" "));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
async fn run_git_trimmed(repo_path: &Path, args: &[&str]) -> anyhow::Result<String> {
Ok(run_git_command(repo_path, args).await?.trim().to_string())
}
async fn run_git_trimmed_env(
repo_path: &Path,
env: &[(&str, &OsStr)],
args: &[&str],
) -> anyhow::Result<String> {
Ok(run_git_command_env(repo_path, env, args)
.await?
.trim()
.to_string())
}
#[must_use]
pub fn has_unstaged_changes(porcelain: &str) -> bool {
porcelain.lines().any(|line| {
let line = line.trim_end();
if line.is_empty() {
return false;
}
line.as_bytes().get(1).is_some_and(|&b| b != b' ')
})
}
pub async fn run_git_add_all(repo_path: &Path) -> anyhow::Result<String> {
run_git_command(repo_path, &["add", "-A"]).await
}
pub async fn run_git_head(repo_path: &Path) -> anyhow::Result<String> {
run_git_trimmed(repo_path, &["rev-parse", "HEAD"]).await
}
pub async fn run_git_write_tree(repo_path: &Path) -> anyhow::Result<String> {
run_git_trimmed(repo_path, &["write-tree"]).await
}
pub async fn run_git_worktree_identity(repo_path: &Path) -> anyhow::Result<(String, String)> {
let dir = tempfile::tempdir().context("Failed to create a throwaway git index")?;
let index = dir.path().join("index");
if let Ok(path) = run_git_trimmed(repo_path, &["rev-parse", "--git-path", "index"]).await {
let path = PathBuf::from(path);
let path = if path.is_absolute() {
path
} else {
repo_path.join(path)
};
match tokio::fs::copy(&path, &index).await {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
warn!(error = %e, path = %path.display(), "Could not seed the throwaway git index");
}
_ => {}
}
}
let env = [("GIT_INDEX_FILE", index.as_os_str())];
run_git_trimmed_env(repo_path, &env, &["add", "-A"]).await?;
let tree = run_git_trimmed_env(repo_path, &env, &["write-tree"]).await?;
Ok((run_git_head(repo_path).await?, tree))
}
pub async fn run_git_commit(repo_path: &Path, message: &str) -> anyhow::Result<CommitInfo> {
run_git_add_all(repo_path).await?;
run_git_command(repo_path, &["commit", "-m", message])
.await
.context("Failed to commit changes")?;
let hash = match run_git_head(repo_path).await {
Ok(hash) => hash,
Err(e) => {
warn!(
error = %e,
"git rev-parse HEAD failed after successful commit — commit exists, returning unknown hash"
);
return Ok(CommitInfo {
hash: "unknown".into(),
lines_added: 0,
lines_removed: 0,
});
}
};
let (lines_added, lines_removed) =
if let Ok(stats) = parse_numstat(repo_path, &["HEAD~1..HEAD"]).await {
stats
} else if let Ok(base) = run_git_empty_tree(repo_path).await {
parse_numstat(repo_path, &[&base, "HEAD"])
.await
.unwrap_or((0, 0))
} else {
(0, 0)
};
Ok(CommitInfo {
hash,
lines_added,
lines_removed,
})
}
pub(crate) static GIT_COMMIT_TX: OnceLock<tokio::sync::broadcast::Sender<PathBuf>> =
OnceLock::new();
pub(crate) fn init_git_commit_tx() {
let _ = GIT_COMMIT_TX.set(tokio::sync::broadcast::channel(64).0);
}
pub(crate) fn notify_git_commit(repo_path: &Path) {
if let Some(tx) = GIT_COMMIT_TX.get() {
let _ = tx.send(repo_path.to_path_buf());
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NumstatEntry {
pub additions: Option<i64>,
pub deletions: Option<i64>,
pub path: String,
}
#[must_use]
pub fn parse_numstat_lines(stdout: &str) -> Vec<NumstatEntry> {
let mut result = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.splitn(3, '\t').collect();
if parts.len() != 3 {
continue;
}
let additions_str = parts[0];
let deletions_str = parts[1];
let path = parts[2].to_string();
if additions_str == "-" || deletions_str == "-" {
result.push(NumstatEntry {
additions: None,
deletions: None,
path,
});
continue;
}
let additions: i64 = additions_str.parse().unwrap_or(0);
let deletions: i64 = deletions_str.parse().unwrap_or(0);
result.push(NumstatEntry {
additions: Some(additions),
deletions: Some(deletions),
path,
});
}
result
}
async fn run_git_diff_numstat(
repo_path: &Path,
range: &[&str],
) -> anyhow::Result<Vec<NumstatEntry>> {
let mut args = vec!["diff", "--numstat"];
args.extend_from_slice(range);
let stdout = run_git_command(repo_path, &args).await?;
Ok(parse_numstat_lines(&stdout))
}
async fn parse_numstat(repo_path: &Path, range: &[&str]) -> anyhow::Result<(i64, i64)> {
let entries = run_git_diff_numstat(repo_path, range).await?;
let mut lines_added: i64 = 0;
let mut lines_removed: i64 = 0;
for entry in entries {
if let Some(added) = entry.additions {
lines_added += added;
}
if let Some(removed) = entry.deletions {
lines_removed += removed;
}
}
Ok((lines_added, lines_removed))
}
pub async fn git_is_installed() -> bool {
let mut cmd = git_command(None);
cmd.arg("--version");
cmd.output().await.is_ok_and(|o| o.status.success())
}
async fn git_has_commits(repo_path: &Path) -> bool {
run_git_head(repo_path).await.is_ok()
}
async fn run_git_empty_tree(repo_path: &Path) -> anyhow::Result<String> {
run_git_trimmed(repo_path, &["hash-object", "-t", "tree", "--stdin"]).await
}
pub async fn run_git_current_branch(repo_path: &Path) -> anyhow::Result<String> {
run_git_trimmed(repo_path, &["rev-parse", "--abbrev-ref", "HEAD"]).await
}
#[derive(Debug, Clone, Copy)]
struct UpstreamCounts {
behind: usize,
ahead: usize,
}
async fn ahead_behind(repo_path: &Path, upstream: &str) -> anyhow::Result<UpstreamCounts> {
let counts = run_git_trimmed(
repo_path,
&[
"rev-list",
"--count",
"--left-right",
&format!("{upstream}...HEAD"),
],
)
.await?;
let (behind, ahead) = counts
.split_once('\t')
.with_context(|| format!("git rev-list printed {counts:?}"))?;
Ok(UpstreamCounts {
behind: behind.trim().parse()?,
ahead: ahead.trim().parse()?,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitPushTarget {
pub remote: String,
pub commits: usize,
pub publish: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitPushDestination {
Target(GitPushTarget),
Nowhere {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitRemoteState {
pub branch: String,
pub behind: usize,
pub push: GitPushDestination,
}
impl GitRemoteState {
#[must_use]
pub fn has_sync_work(&self) -> bool {
match &self.push {
GitPushDestination::Nowhere { .. } => true,
GitPushDestination::Target(target) => self.behind > 0 || target.commits > 0,
}
}
}
pub async fn run_git_remote_state(repo_path: &Path) -> anyhow::Result<Option<GitRemoteState>> {
let Some(lane) = resolve_push_lane(repo_path).await? else {
return Ok(None);
};
Ok(Some(GitRemoteState {
branch: lane.branch,
behind: lane.upstream_counts.map_or(0, |counts| counts.behind),
push: lane.destination,
}))
}
struct BranchRemotes {
upstream: Option<String>,
upstream_ref: Option<String>,
push: Option<String>,
}
struct PushLane {
branch: String,
remotes: BranchRemotes,
upstream_counts: Option<UpstreamCounts>,
destination: GitPushDestination,
}
async fn resolve_push_lane(repo_path: &Path) -> anyhow::Result<Option<PushLane>> {
let Some(branch) = checked_out_branch(repo_path).await else {
return Ok(None);
};
let pushable: Vec<String> = remotes(repo_path)
.await?
.into_iter()
.filter_map(|(name, accepts_pushes)| accepts_pushes.then_some(name))
.collect();
let own = branch_remotes(repo_path, &branch).await?;
let upstream_counts = match &own.upstream_ref {
Some(upstream) => Some(ahead_behind(repo_path, upstream).await?),
None => None,
};
let push_tip = match (&own.upstream_ref, upstream_counts) {
(Some(upstream), Some(counts)) if counts.behind > 0 && counts.ahead == 0 => {
upstream.as_str()
}
_ => "HEAD",
};
let accepts = |name: Option<&str>| {
name.filter(|name| pushable.iter().any(|remote| remote == name))
.map(str::to_string)
};
let destination_remote = accepts(own.push.as_deref())
.or_else(|| accepts(own.upstream.as_deref()))
.or_else(|| match pushable.as_slice() {
[only] => Some(only.clone()),
_ => None,
});
let destination = match destination_remote.as_deref() {
Some(remote) => {
let tracked_destination = own.upstream.as_deref() == Some(remote);
let position = match upstream_counts {
Some(counts) if tracked_destination => DestinationPosition {
publish: Some(false),
commits: counts.ahead,
},
_ => destination_position(repo_path, remote, &branch, push_tip).await?,
};
GitPushDestination::Target(GitPushTarget {
remote: remote.to_string(),
commits: position.commits,
publish: position.publish,
})
}
None => GitPushDestination::Nowhere {
reason: match pushable.as_slice() {
[] => "no remote accepts pushes".to_string(),
several => format!("several remotes accept pushes ({})", several.join(", ")),
},
},
};
Ok(Some(PushLane {
branch,
remotes: own,
upstream_counts,
destination,
}))
}
async fn checked_out_branch(repo_path: &Path) -> Option<String> {
run_git_current_branch(repo_path)
.await
.ok()
.filter(|branch| branch != "HEAD")
}
struct DestinationPosition {
publish: Option<bool>,
commits: usize,
}
async fn destination_position(
repo_path: &Path,
remote: &str,
branch: &str,
push_tip: &str,
) -> anyhow::Result<DestinationPosition> {
let wanted = format!("refs/heads/{branch}");
let listing = match run_git_network(
repo_path,
&["ls-remote", "--branches", remote, &wanted],
FETCH_TIMEOUT,
)
.await
{
Ok(output) => output.stdout,
Err(e) => {
tracing::debug!(error = %e, %remote, %branch, "git ls-remote failed");
return unreported_position(repo_path).await;
}
};
let tip = listing.lines().find_map(|line| {
let (tip, refname) = line.split_once('\t')?;
(refname.trim() == wanted).then(|| tip.trim().to_string())
});
let Some(tip) = tip else {
return Ok(DestinationPosition {
publish: Some(true),
commits: count_commits(repo_path, "HEAD").await?,
});
};
if !rev_exists(repo_path, &tip).await? && !fetch_destination(repo_path, remote, branch).await {
return unreported_position(repo_path).await;
}
Ok(DestinationPosition {
publish: Some(false),
commits: count_commits(repo_path, &format!("{tip}..{push_tip}")).await?,
})
}
async fn unreported_position(repo_path: &Path) -> anyhow::Result<DestinationPosition> {
Ok(DestinationPosition {
publish: None,
commits: count_commits(repo_path, "HEAD").await?,
})
}
async fn fetch_destination(repo_path: &Path, remote: &str, branch: &str) -> bool {
if FETCH_IN_FLIGHT.swap(true, Ordering::AcqRel) {
tracing::debug!(%remote, %branch, "skipping the destination fetch: another fetch is running");
return false;
}
let _guard = FetchInFlightGuard;
let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
match run_git_network(
repo_path,
&[
"fetch",
"--quiet",
"--no-write-fetch-head",
"--no-tags",
remote,
&refspec,
],
FETCH_TIMEOUT,
)
.await
{
Ok(_) => true,
Err(e) => {
tracing::debug!(error = %e, %remote, %branch, "git fetch of the push destination failed");
false
}
}
}
async fn remotes(repo_path: &Path) -> anyhow::Result<Vec<(String, bool)>> {
let output = run_git_command(repo_path, &["remote", "-v"]).await?;
let mut remotes: Vec<(String, bool)> = Vec::new();
for line in output.lines() {
let Some((name, target)) = line.split_once('\t') else {
continue;
};
let Some(url) = target.strip_suffix(" (push)") else {
continue;
};
let usable = push_destination_accepts_pushes(repo_path, url);
match remotes.iter_mut().find(|(remote, _)| remote == name) {
Some((_, all_usable)) => *all_usable &= usable,
None => remotes.push((name.to_string(), usable)),
}
}
Ok(remotes)
}
fn push_destination_accepts_pushes(repo_path: &Path, url: &str) -> bool {
let url = url.trim();
if url.is_empty() {
return false;
}
if url.contains([':', '/', '\\']) || url.starts_with(['.', '~']) {
return true;
}
repo_path.join(url).exists()
}
async fn branch_remotes(repo_path: &Path, branch: &str) -> anyhow::Result<BranchRemotes> {
let listing = run_git_command(
repo_path,
&[
"for-each-ref",
"--format=%(upstream)%09%(upstream:remotename)%09%(push:remotename)",
&format!("refs/heads/{branch}"),
],
)
.await?;
let mut fields = listing.trim_end().split('\t');
let upstream_ref = fields.next().unwrap_or_default();
let upstream = fields.next().unwrap_or_default();
let push = fields.next().unwrap_or_default();
let upstream_ref = if !upstream_ref.is_empty() && rev_exists(repo_path, upstream_ref).await? {
Some(upstream_ref.to_string())
} else {
None
};
Ok(BranchRemotes {
upstream: Some(upstream)
.filter(|name| !name.is_empty())
.map(str::to_string),
upstream_ref,
push: Some(push)
.filter(|name| !name.is_empty())
.map(str::to_string),
})
}
async fn count_commits(repo_path: &Path, rev: &str) -> anyhow::Result<usize> {
let count = run_git_trimmed(repo_path, &["rev-list", "--count", rev]).await?;
count
.parse()
.with_context(|| format!("git rev-list --count {rev} printed {count:?}"))
}
async fn rev_exists(repo_path: &Path, rev: &str) -> anyhow::Result<bool> {
Ok(run_git_output(
repo_path,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("{rev}^{{commit}}"),
],
)
.await?
.status
.success())
}
const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
static FETCH_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
struct FetchInFlightGuard;
impl Drop for FetchInFlightGuard {
fn drop(&mut self) {
FETCH_IN_FLIGHT.store(false, Ordering::Release);
}
}
pub async fn run_git_fetch(repo_path: &Path) -> anyhow::Result<String> {
if FETCH_IN_FLIGHT.swap(true, Ordering::AcqRel) {
return Ok(String::new());
}
let _guard = FetchInFlightGuard;
run_git_network(repo_path, &["fetch", "--quiet"], FETCH_TIMEOUT)
.await
.map(GitNetworkOutput::report)
}
struct GitNetworkOutput {
stderr: String,
stdout: String,
}
impl GitNetworkOutput {
fn report(self) -> String {
[self.stderr, self.stdout]
.into_iter()
.filter(|stream| !stream.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
}
async fn run_git_network(
repo_path: &Path,
args: &[&str],
timeout: Duration,
) -> anyhow::Result<GitNetworkOutput> {
let mut cmd = git_command(Some(repo_path));
cmd.args(args).current_dir(repo_path);
cmd.env("GIT_TERMINAL_PROMPT", "0");
if let Some(sock) = std::env::var_os("SSH_AUTH_SOCK") {
cmd.env("SSH_AUTH_SOCK", sock);
}
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.kill_on_drop(true);
let mut child = cmd
.spawn()
.with_context(|| format!("Failed to spawn git {}", args.join(" ")))?;
let mut stdout = child.stdout.take();
let mut stderr = child.stderr.take();
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
let wait = child.wait();
let read_stdout = async {
if let Some(out) = &mut stdout {
let _ = out.read_to_end(&mut stdout_buf).await;
}
};
let read_stderr = async {
if let Some(err) = &mut stderr {
let _ = err.read_to_end(&mut stderr_buf).await;
}
};
let status = if let Ok(res) = tokio::time::timeout(timeout, async {
tokio::join!(wait, read_stdout, read_stderr).0
})
.await
{
res.with_context(|| format!("git {} wait failed", args.join(" ")))?
} else {
let _ = child.kill().await;
let _ = child.wait().await;
anyhow::bail!(
"git {} timed out after {}s",
args.join(" "),
timeout.as_secs()
);
};
if !status.success() {
anyhow::bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&stderr_buf)
);
}
Ok(GitNetworkOutput {
stderr: String::from_utf8_lossy(&stderr_buf).trim().to_string(),
stdout: String::from_utf8_lossy(&stdout_buf).trim().to_string(),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DiffStats {
pub added: i64,
pub removed: i64,
pub huge_binary_file_count: usize,
pub omitted_file_count: usize,
}
impl DiffStats {
pub(crate) fn omitted_note(&self) -> Option<String> {
(self.omitted_file_count > 0).then(|| {
format!(
"{} uncommitted file{} left out (worktree read cap)",
self.omitted_file_count,
if self.omitted_file_count == 1 {
""
} else {
"s"
}
)
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UntrackedKind {
Text,
Binary,
TooLarge(u64),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeChange {
pub path: String,
pub old_path: Option<String>,
pub added: i64,
pub removed: i64,
pub untracked: Option<UntrackedKind>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitFileStatus {
Modified,
Added,
}
async fn run_git_worktree_numstat(
repo_path: &Path,
base: &str,
) -> anyhow::Result<Vec<WorktreeChange>> {
let stdout = run_git_command(
repo_path,
&["diff", "--numstat", "-z", "--find-renames", base],
)
.await?;
Ok(parse_numstat_z(&stdout))
}
fn parse_numstat_z(stdout: &str) -> Vec<WorktreeChange> {
let mut fields = stdout.split('\0');
let mut result = Vec::new();
while let Some(field) = fields.next() {
if field.is_empty() {
continue;
}
let mut parts = field.splitn(3, '\t');
let added = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
let removed = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
let Some(path) = parts.next() else {
continue;
};
let (path, old_path) = if path.is_empty() {
let (Some(old), Some(new)) = (fields.next(), fields.next()) else {
break;
};
(new.to_string(), Some(old.to_string()))
} else {
(path.to_string(), None)
};
result.push(WorktreeChange {
path,
old_path,
added,
removed,
untracked: None,
});
}
result
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitWorktreeSnapshot {
pub stats: DiffStats,
pub file_statuses: HashMap<String, GitFileStatus>,
pub unborn_head: bool,
pub changes: Vec<WorktreeChange>,
pub repo_root: PathBuf,
}
fn untracked_change(path: &str, kind: UntrackedKind, added: i64) -> WorktreeChange {
WorktreeChange {
path: path.to_owned(),
old_path: None,
added,
removed: 0,
untracked: Some(kind),
}
}
async fn classify_untracked_changes(
repo_root: &Path,
untracked: &[String],
) -> (Vec<WorktreeChange>, usize) {
let mut changes = Vec::new();
let mut files_processed = 0usize;
let mut bytes_read = 0u64;
let mut omitted = 0usize;
for path in untracked {
if files_processed >= MAX_UNTRACKED_FILES_COUNTED || bytes_read >= MAX_UNTRACKED_BYTES_READ
{
omitted += 1;
continue;
}
let full_path = repo_root.join(path);
let Ok(meta) = tokio::fs::metadata(&full_path).await else {
continue;
};
if !meta.is_file() {
continue;
}
files_processed += 1;
if meta.len() > MAX_UNTRACKED_SIZE {
changes.push(untracked_change(
path,
UntrackedKind::TooLarge(meta.len()),
0,
));
continue;
}
if bytes_read + meta.len() > MAX_UNTRACKED_BYTES_READ {
omitted += 1;
continue;
}
match read_untracked_file_with_meta(&full_path, &meta).await {
UntrackedFileRead::Text(text) => {
bytes_read += meta.len();
#[expect(clippy::cast_possible_wrap)] let added = text.lines().count() as i64;
changes.push(untracked_change(path, UntrackedKind::Text, added));
}
UntrackedFileRead::Binary => {
bytes_read += meta.len();
changes.push(untracked_change(path, UntrackedKind::Binary, 0));
}
UntrackedFileRead::TooLarge(size) => {
changes.push(untracked_change(path, UntrackedKind::TooLarge(size), 0));
}
UntrackedFileRead::Skip => {}
}
}
(changes, omitted)
}
pub async fn run_git_worktree_snapshot(repo_path: &Path) -> anyhow::Result<GitWorktreeSnapshot> {
let repo_root =
resolve_git_top_level(repo_path).context("Failed to resolve the repository top-level")?;
let porcelain = run_git_status_all(&repo_root).await?;
let file_statuses = parse_git_status_porcelain(&porcelain);
let untracked = parse_untracked_from_porcelain(&porcelain);
let (tracked, unborn_head) = match run_git_worktree_numstat(&repo_root, "HEAD").await {
Ok(entries) => (entries, false),
Err(_) if !git_has_commits(&repo_root).await => {
let base = run_git_empty_tree(&repo_root).await?;
(
run_git_worktree_numstat(&repo_root, &base)
.await
.context("Failed to compute working-tree diff stats")?,
true,
)
}
Err(e) => return Err(e.context("Failed to compute working-tree diff stats")),
};
let mut changes = tracked;
let (untracked_changes, omitted) = classify_untracked_changes(&repo_root, &untracked).await;
changes.extend(untracked_changes);
let mut stats = DiffStats {
added: 0,
removed: 0,
huge_binary_file_count: 0,
omitted_file_count: omitted,
};
for change in &changes {
stats.added += change.added;
stats.removed += change.removed;
if matches!(
change.untracked,
Some(UntrackedKind::Binary | UntrackedKind::TooLarge(_))
) {
stats.huge_binary_file_count += 1;
}
}
Ok(GitWorktreeSnapshot {
stats,
file_statuses,
unborn_head,
changes,
repo_root,
})
}
const SYNC_TIMEOUT: Duration = Duration::from_secs(300);
#[derive(Debug, Clone)]
pub struct GitSyncReport {
pub output: String,
pub not_pushed: Option<String>,
}
pub async fn run_git_sync(repo_path: &Path) -> anyhow::Result<GitSyncReport> {
let lane = resolve_push_lane(repo_path)
.await?
.context("no branch is checked out")?;
let publish = matches!(&lane.destination, GitPushDestination::Target(target) if target.publish == Some(true));
let update = if lane.remotes.upstream_ref.is_some() && !publish {
Some(
run_git_network(repo_path, &["pull", "--ff-only"], SYNC_TIMEOUT)
.await?
.report(),
)
} else {
None
};
let (not_pushed, push) = match &lane.destination {
GitPushDestination::Nowhere { reason } => (Some(reason.clone()), None),
GitPushDestination::Target(target) => {
let record_upstream = lane.remotes.upstream.is_none();
let plain =
!record_upstream && lane.remotes.push.as_deref() == Some(target.remote.as_str());
let report = if plain {
run_git_network(repo_path, &["push"], SYNC_TIMEOUT)
.await?
.report()
} else {
let mut args = vec!["push"];
if record_upstream {
args.push("--set-upstream");
}
args.push(target.remote.as_str());
args.push(lane.branch.as_str());
run_git_network(repo_path, &args, SYNC_TIMEOUT)
.await?
.report()
};
(None, Some(report))
}
};
Ok(GitSyncReport {
output: update
.into_iter()
.chain(push)
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("\n"),
not_pushed,
})
}
pub async fn run_git_discard(
repo_path: &Path,
path: &str,
target: DiscardTarget,
) -> anyhow::Result<()> {
let path = format!(":(top,literal){path}");
let _ = run_git_command(repo_path, &["checkout", "HEAD", "--", &path]).await;
let _ = run_git_command(repo_path, &["reset", "HEAD", "--", &path]).await;
let clean_args: &[&str] = match target {
DiscardTarget::Directory => &["clean", "-fd", "--", &path],
DiscardTarget::File => &["clean", "-f", "--", &path],
};
let _ = run_git_command(repo_path, clean_args).await;
let status_args = [
"status",
"--porcelain",
"--untracked-files=normal",
"--",
&path,
];
match run_git_command(repo_path, &status_args).await {
Ok(status) if status.trim().is_empty() => Ok(()),
Ok(status) => anyhow::bail!("Changes remain after discard:\n{}", status.trim()),
Err(e) => anyhow::bail!("Discard ran but verification failed: {e}"),
}
}
pub async fn run_git_commit_message(
repo_path: &Path,
commit_hash: Option<&str>,
) -> anyhow::Result<String> {
let mut args = vec!["log", "-1", "--format=%s"];
if let Some(hash) = commit_hash {
args.push(hash);
}
run_git_trimmed(repo_path, &args).await
}
pub(crate) async fn list_new_or_untracked_files(repo_path: &Path) -> anyhow::Result<Vec<String>> {
let porcelain = run_git_status(repo_path).await?;
Ok(parse_new_files_from_porcelain(&porcelain))
}
struct PorcelainRecord {
index: char,
worktree: char,
path: String,
}
fn parse_porcelain_record(line: &str) -> Option<PorcelainRecord> {
let mut chars = line.chars();
let index = chars.next()?;
let worktree = chars.next()?;
if chars.next()? != ' ' {
return None;
}
let field = chars.as_str();
if field.is_empty() {
return None;
}
let path = if index == 'R' || index == 'C' || worktree == 'R' || worktree == 'C' {
split_rename_paths(field)?
} else {
unquote_c_style(field)?
};
Some(PorcelainRecord {
index,
worktree,
path,
})
}
fn parse_porcelain_paths(
porcelain: &str,
mut select: impl FnMut(&PorcelainRecord) -> bool,
) -> Vec<String> {
porcelain
.lines()
.filter_map(parse_porcelain_record)
.filter(|record| select(record))
.map(|record| record.path)
.collect()
}
fn split_rename_paths(field: &str) -> Option<String> {
let mut quoted = false;
let mut escaped = false;
for (at, c) in field.char_indices() {
if escaped {
escaped = false;
} else if quoted && c == '\\' {
escaped = true;
} else if c == '"' {
quoted = !quoted;
} else if !quoted && let Some(destination) = field[at..].strip_prefix(" -> ") {
return unquote_c_style(destination).filter(|path| !path.is_empty());
}
}
None
}
#[must_use]
fn parse_new_files_from_porcelain(porcelain: &str) -> Vec<String> {
parse_porcelain_paths(porcelain, |record| {
record.index == 'A' || (record.index == '?' && record.worktree == '?')
})
}
#[must_use]
fn parse_untracked_from_porcelain(porcelain: &str) -> Vec<String> {
parse_porcelain_paths(porcelain, |record| {
record.index == '?' && record.worktree == '?'
})
.into_iter()
.filter(|path| !path.ends_with('/'))
.collect()
}
#[must_use]
pub(crate) fn parse_git_status_porcelain(output: &str) -> HashMap<String, GitFileStatus> {
let mut map: HashMap<String, GitFileStatus> = HashMap::new();
for line in output.lines() {
let trimmed = line.trim_end();
if trimmed.len() < 2 {
continue;
}
let chars: Vec<char> = trimmed.chars().take(2).collect();
if chars.len() < 2 {
continue;
}
let ix = chars[0];
let wt = chars[1];
if ix == 'D' || wt == 'D' {
continue;
}
if ix == 'R' || wt == 'R' {
let Some(new_path) = split_rename_paths(trimmed[2..].trim_start()) else {
continue;
};
map.insert(new_path, GitFileStatus::Modified);
continue;
}
if ix == 'C' || wt == 'C' {
continue;
}
let path = trimmed[2..].trim_start();
if path.is_empty() {
continue;
}
let status = if ix == 'M' || wt == 'M' {
GitFileStatus::Modified
} else if ix == 'A' || wt == 'A' || (ix == '?' && wt == '?') {
GitFileStatus::Added
} else {
continue;
};
let Some(path) = unquote_c_style(path) else {
continue;
};
let path = path.strip_suffix('/').unwrap_or(&path).to_string();
let entry = map.entry(path).or_insert(status);
if status == GitFileStatus::Modified && *entry == GitFileStatus::Added {
*entry = GitFileStatus::Modified;
}
}
map
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::test::init_temp_repo;
#[tokio::test]
async fn worktree_identity_is_read_only_and_matches_a_real_add() {
let (_dir, repo_path) = init_temp_repo();
std::fs::write(repo_path.join("tracked.txt"), b"changed\n").unwrap();
std::fs::write(repo_path.join("untracked.txt"), b"new\n").unwrap();
let status_before = run_git_status(&repo_path).await.unwrap();
let index_before = run_git_write_tree(&repo_path).await.unwrap();
let (head, tree) = run_git_worktree_identity(&repo_path).await.unwrap();
assert_eq!(
status_before,
run_git_status(&repo_path).await.unwrap(),
"the reading must not change the working-tree status",
);
assert_eq!(
index_before,
run_git_write_tree(&repo_path).await.unwrap(),
"the reading must not touch the repository's own index",
);
run_git_add_all(&repo_path).await.unwrap();
assert_eq!(
tree,
run_git_write_tree(&repo_path).await.unwrap(),
"the identity must be what a real add -A stages",
);
assert_eq!(head, run_git_head(&repo_path).await.unwrap());
}
#[tokio::test]
async fn worktree_identity_seeds_a_split_index_and_ignored_tracked_paths() {
let (_dir, repo_path) = init_temp_repo();
let status = std::process::Command::new("git")
.args(["update-index", "--split-index"])
.current_dir(&repo_path)
.status()
.expect("git update-index");
assert!(status.success());
std::fs::write(repo_path.join(".gitignore"), b"test.txt\n").unwrap();
std::fs::write(repo_path.join("test.txt"), b"v2\n").unwrap();
std::fs::write(repo_path.join("untracked.txt"), b"new\n").unwrap();
let (_, tree) = run_git_worktree_identity(&repo_path).await.unwrap();
run_git_add_all(&repo_path).await.unwrap();
assert_eq!(
tree,
run_git_write_tree(&repo_path).await.unwrap(),
"the identity must be what a real add -A stages",
);
}
#[test]
fn short_hash_cases() {
let cases = [
(
"long hash truncated to 7 chars",
"abc1234def5678",
"abc1234",
),
("short hash returned as-is", "abc12", "abc12"),
("exactly 7 chars returned as-is", "abc1234", "abc1234"),
];
for (name, hash, expected) in &cases {
let info = CommitInfo {
hash: hash.to_string(),
lines_added: 0,
lines_removed: 0,
};
assert_eq!(info.short_hash(), *expected, "{name}");
}
}
#[test]
fn resolve_git_top_level_repo_root() {
let (_dir, repo_path) = init_temp_repo();
assert_eq!(
resolve_git_top_level(&repo_path),
Some(crate::util::test::canonical_without_verbatim_prefix(
&repo_path
))
);
}
#[test]
fn resolve_git_top_level_from_subdir() {
let (_dir, repo_path) = init_temp_repo();
let subdir = repo_path.join("src");
std::fs::create_dir(&subdir).expect("create subdir");
assert_eq!(
resolve_git_top_level(&subdir),
Some(crate::util::test::canonical_without_verbatim_prefix(
&repo_path
))
);
}
#[test]
fn resolve_git_top_level_non_repo_is_none() {
let dir = tempfile::tempdir().expect("create temp dir");
assert_eq!(
resolve_git_top_level(dir.path()),
None,
"non-repo dir must resolve to None"
);
}
#[test]
fn resolve_git_top_level_accepts_git_file() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(dir.path().join(".git"), b"gitdir: /elsewhere").expect("write .git file");
assert_eq!(
resolve_git_top_level(dir.path()),
Some(crate::util::test::canonical_without_verbatim_prefix(
dir.path()
))
);
}
fn hermetic_git_command(repo_root: Option<&Path>) -> tokio::process::Command {
let mut cmd = git_command(repo_root);
cmd.env("GIT_CONFIG_GLOBAL", "/dev/null");
cmd.env("GIT_CONFIG_NOSYSTEM", "1");
cmd.env("GIT_TEST_ASSUME_DIFFERENT_OWNER", "1");
cmd
}
#[tokio::test]
async fn test_safe_directory_injection_bypasses_dubious_ownership() {
let (_dir, repo_path) = init_temp_repo();
let mut cmd = hermetic_git_command(Some(&repo_path));
cmd.arg("status").arg("--porcelain").current_dir(&repo_path);
let out = cmd.output().await.expect("run git status");
assert!(
out.status.success(),
"git status must succeed with safe.directory injected: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[tokio::test]
async fn test_without_safe_directory_fails_dubious_ownership() {
let (_dir, repo_path) = init_temp_repo();
let mut cmd = hermetic_git_command(None);
cmd.arg("status").arg("--porcelain").current_dir(&repo_path);
let out = cmd.output().await.expect("run git status");
assert!(
!out.status.success(),
"git must refuse dubious ownership without safe.directory injection"
);
}
#[tokio::test]
async fn test_safe_directory_injects_top_level_for_subdir_workspace() {
let (_dir, repo_path) = init_temp_repo();
let subdir = repo_path.join("src");
std::fs::create_dir(&subdir).expect("create subdir");
let mut cmd = hermetic_git_command(Some(&subdir));
cmd.arg("status").arg("--porcelain").current_dir(&subdir);
let out = cmd.output().await.expect("run git status");
assert!(
out.status.success(),
"git from a repo subdir must match the repo top-level safe.directory: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[tokio::test]
async fn test_git_has_commits_true() {
let (_dir, repo_path) = init_temp_repo();
let has = git_has_commits(&repo_path).await;
assert!(has, "repo with initial commit should have commits");
}
#[tokio::test]
async fn test_run_git_head_and_write_tree_fingerprint() {
let (_dir, repo_path) = init_temp_repo();
let head1 = run_git_head(&repo_path).await.expect("repo has commits");
let tree1 = run_git_write_tree(&repo_path)
.await
.expect("index writable");
std::fs::write(repo_path.join("test.txt"), b"line1\nline2\n").expect("write file");
run_git_add_all(&repo_path).await.expect("git add");
let head2 = run_git_head(&repo_path).await.expect("repo has commits");
let tree2 = run_git_write_tree(&repo_path)
.await
.expect("index writable");
assert_eq!(head1, head2, "staging must not change HEAD");
assert_ne!(tree1, tree2, "staging must change the index tree");
}
#[tokio::test]
async fn test_run_git_head_none_without_commits() {
let dir = tempfile::tempdir().expect("create temp dir");
let repo_path = dir.path().to_path_buf();
let status = std::process::Command::new("git")
.args(["init"])
.current_dir(&repo_path)
.status()
.expect("git init");
assert!(status.success());
let head = run_git_head(&repo_path).await.ok();
assert!(head.is_none(), "commit-less repo must not resolve HEAD");
}
#[tokio::test]
async fn test_run_git_current_branch_default() {
let (_dir, repo_path) = init_temp_repo();
let branch = run_git_current_branch(&repo_path).await.expect("branch");
assert!(!branch.is_empty(), "branch name should not be empty");
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_clean_tree() {
let (_dir, repo_path) = init_temp_repo();
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(snap.stats.added, 0);
assert_eq!(snap.stats.removed, 0);
assert_eq!(snap.stats.huge_binary_file_count, 0);
assert_eq!(snap.stats.omitted_file_count, 0);
assert!(snap.changes.is_empty(), "clean tree has no changes");
assert!(snap.file_statuses.is_empty(), "clean tree has no statuses");
assert_eq!(
snap.repo_root,
std::fs::canonicalize(&repo_path).unwrap(),
"the snapshot resolves the repository root"
);
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_with_changes() {
let (_dir, repo_path) = init_temp_repo();
std::fs::write(
repo_path.join("test.txt"),
b"line1\nline2 modified\nline3\nline4\n",
)
.expect("write modified file");
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(snap.stats.added, 2, "two lines added (modified + new line)");
assert_eq!(snap.stats.removed, 1, "one line removed (line2)");
assert_eq!(snap.stats.huge_binary_file_count, 0);
assert_eq!(
snap.changes,
vec![WorktreeChange {
path: "test.txt".into(),
old_path: None,
added: 2,
removed: 1,
untracked: None,
}]
);
assert_eq!(
snap.file_statuses.get("test.txt"),
Some(&GitFileStatus::Modified)
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_run_git_worktree_snapshot_lists_mode_only_change_without_counts() {
use std::os::unix::fs::PermissionsExt;
let (_dir, repo_path) = init_temp_repo();
let path = repo_path.join("test.txt");
let mut permissions = std::fs::metadata(&path)
.expect("test file metadata")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&path, permissions).expect("make test file executable");
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!((snap.stats.added, snap.stats.removed), (0, 0));
assert_eq!(
snap.changes,
vec![WorktreeChange {
path: "test.txt".into(),
old_path: None,
added: 0,
removed: 0,
untracked: None,
}]
);
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_with_untracked() {
let (_dir, repo_path) = init_temp_repo();
std::fs::write(
repo_path.join("new_file.rs"),
b"fn foo() {\n bar();\n}\n",
)
.expect("write untracked file");
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(
snap.stats.added, 3,
"should count lines from untracked file"
);
assert_eq!(snap.stats.removed, 0, "no removed lines");
assert_eq!(snap.stats.huge_binary_file_count, 0);
assert_eq!(
snap.changes,
vec![WorktreeChange {
path: "new_file.rs".into(),
old_path: None,
added: 3,
removed: 0,
untracked: Some(UntrackedKind::Text),
}]
);
assert_eq!(
snap.file_statuses.get("new_file.rs"),
Some(&GitFileStatus::Added)
);
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_counts_binary_untracked_as_huge() {
let (_dir, repo_path) = init_temp_repo();
std::fs::write(repo_path.join("binary.bin"), b"line1\nline2\x00\n")
.expect("write binary file");
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(
snap.stats.added, 0,
"binary untracked file should not be counted as added"
);
assert_eq!(snap.stats.removed, 0, "no removed lines");
assert_eq!(
snap.stats.huge_binary_file_count, 1,
"binary untracked file should be surfaced as huge/binary"
);
assert_eq!(
snap.changes,
vec![WorktreeChange {
path: "binary.bin".into(),
old_path: None,
added: 0,
removed: 0,
untracked: Some(UntrackedKind::Binary),
}]
);
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_counts_large_untracked_as_huge() {
let (_dir, repo_path) = init_temp_repo();
let size = usize::try_from(MAX_UNTRACKED_SIZE).unwrap() + 1;
let mut content = Vec::with_capacity(size);
content.resize(size, b'a');
std::fs::write(repo_path.join("large.bin"), &content).expect("write large file");
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(
snap.stats.added, 0,
"large untracked file should not be counted as added"
);
assert_eq!(snap.stats.removed, 0, "no removed lines");
assert_eq!(
snap.stats.huge_binary_file_count, 1,
"large untracked file should be surfaced as huge/binary"
);
assert_eq!(
snap.changes,
vec![WorktreeChange {
path: "large.bin".into(),
old_path: None,
added: 0,
removed: 0,
untracked: Some(UntrackedKind::TooLarge(u64::try_from(size).unwrap())),
}]
);
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_counts_untracked_directory_files() {
let (_dir, repo_path) = init_temp_repo();
std::fs::create_dir_all(repo_path.join("new_dir")).expect("create directory");
std::fs::write(
repo_path.join("new_dir/a.rs"),
b"fn foo() {\n bar();\n}\n",
)
.expect("write normal file");
std::fs::write(repo_path.join("new_dir/binary.bin"), b"line1\x00\n")
.expect("write binary file");
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(
snap.stats.added, 3,
"should count lines from directory files"
);
assert_eq!(snap.stats.removed, 0, "no removed lines");
assert_eq!(
snap.stats.huge_binary_file_count, 1,
"binary file inside the directory surfaces as huge/binary"
);
assert_eq!(
snap.changes,
vec![
WorktreeChange {
path: "new_dir/a.rs".into(),
old_path: None,
added: 3,
removed: 0,
untracked: Some(UntrackedKind::Text),
},
WorktreeChange {
path: "new_dir/binary.bin".into(),
old_path: None,
added: 0,
removed: 0,
untracked: Some(UntrackedKind::Binary),
},
]
);
assert!(
!snap.changes.iter().any(|c| c.path == "new_dir/"),
"the directory itself is never a change"
);
}
#[expect(clippy::cast_possible_wrap)] #[tokio::test]
async fn test_run_git_worktree_snapshot_bounded_file_count_cap() {
let (_dir, repo_path) = init_temp_repo();
std::fs::create_dir_all(repo_path.join("big_dir")).expect("create directory");
for i in 0..(MAX_UNTRACKED_FILES_COUNTED + 50) {
std::fs::write(
repo_path.join("big_dir").join(format!("f{i}")),
format!("x{i}\n"),
)
.expect("write counted file");
}
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(
snap.stats.added, MAX_UNTRACKED_FILES_COUNTED as i64,
"only the first cap files are line-counted"
);
assert_eq!(snap.stats.removed, 0);
assert_eq!(snap.stats.huge_binary_file_count, 0);
assert_eq!(
snap.changes.len(),
MAX_UNTRACKED_FILES_COUNTED,
"the manifest only carries files inside the cap"
);
assert_eq!(
snap.stats.omitted_file_count,
MAX_UNTRACKED_FILES_COUNTED + 50 - MAX_UNTRACKED_FILES_COUNTED,
"files beyond the cap are reported as omitted"
);
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_gitignore_respected_in_directory() {
let (_dir, repo_path) = init_temp_repo();
std::fs::write(repo_path.join(".gitignore"), b"ignored.log\n").expect("write gitignore");
run_git_command(&repo_path, &["add", ".gitignore"])
.await
.expect("git add .gitignore");
run_git_command(&repo_path, &["commit", "-m", "add .gitignore"])
.await
.expect("git commit .gitignore");
std::fs::create_dir_all(repo_path.join("new_dir")).expect("create directory");
std::fs::write(repo_path.join("new_dir/keep.rs"), b"aa\nbb\n").expect("write kept file");
std::fs::write(repo_path.join("new_dir/ignored.log"), b"ignored\n")
.expect("write ignored file");
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(
snap.stats.added, 2,
"gitignored file inside a directory is not counted"
);
assert_eq!(snap.stats.removed, 0);
assert_eq!(snap.stats.huge_binary_file_count, 0);
assert_eq!(snap.changes.len(), 1, "only the kept file is a change");
assert_eq!(snap.changes[0].path, "new_dir/keep.rs");
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_unborn_head() {
let dir = tempfile::tempdir().expect("create temp dir");
let repo_path = dir.path().to_path_buf();
let status = std::process::Command::new("git")
.args(["init"])
.current_dir(&repo_path)
.status()
.expect("git init");
assert!(status.success());
std::fs::write(repo_path.join("first.rs"), b"fn main() {}\n")
.expect("write untracked file");
std::fs::write(repo_path.join("staged.rs"), b"a\nb\n").expect("write staged file");
run_git_command(&repo_path, &["add", "staged.rs"])
.await
.expect("git add staged file");
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(
snap.stats.added, 3,
"staged and untracked lines both counted despite unborn HEAD"
);
assert_eq!(snap.stats.removed, 0);
assert_eq!(snap.stats.huge_binary_file_count, 0);
assert!(snap.unborn_head, "unborn HEAD must be flagged");
assert_eq!(
snap.file_statuses.get("first.rs"),
Some(&GitFileStatus::Added),
"untracked file status flows on an unborn repo"
);
let staged = snap
.changes
.iter()
.find(|c| c.path == "staged.rs")
.expect("staged file is in the change set");
assert_eq!((staged.added, staged.removed), (2, 0));
assert!(staged.untracked.is_none(), "a staged file is tracked");
assert_eq!(
snap.changes
.iter()
.find(|c| c.path == "first.rs")
.and_then(|c| c.untracked),
Some(UntrackedKind::Text)
);
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_resolves_root_relative_paths_from_subdir() {
let (_dir, repo_path) = init_temp_repo();
let sub = repo_path.join("sub");
std::fs::create_dir(&sub).expect("create subdir");
std::fs::write(sub.join("inside.txt"), b"one\ntwo\n").expect("write inside file");
std::fs::write(repo_path.join("at-root.txt"), b"root\n").expect("write root file");
let snap = run_git_worktree_snapshot(&sub)
.await
.expect("worktree snapshot");
let paths: Vec<&str> = snap.changes.iter().map(|c| c.path.as_str()).collect();
assert!(
paths.contains(&"sub/inside.txt"),
"a subdirectory workspace still reports repo-root-relative paths: {paths:?}"
);
assert!(
paths.contains(&"at-root.txt"),
"the reading is repo-wide: {paths:?}"
);
assert_eq!(snap.stats.added, 3, "both files are counted");
assert_eq!(snap.repo_root, std::fs::canonicalize(&repo_path).unwrap());
}
#[test]
fn parse_numstat_z_handles_normal_binary_and_rename_records() {
let stdout = "3\t1\tfile.txt\0-\t-\timage.bin\0\
2\t0\t\0old.txt\0new.txt\0";
let entries = parse_numstat_z(stdout);
assert_eq!(
entries.len(),
3,
"trailing NUL and empty fields are ignored"
);
assert_eq!(entries[0].path, "file.txt");
assert_eq!((entries[0].added, entries[0].removed), (3, 1));
assert_eq!(entries[0].old_path, None);
assert_eq!(entries[1].path, "image.bin");
assert_eq!((entries[1].added, entries[1].removed), (0, 0));
assert_eq!(entries[2].path, "new.txt");
assert_eq!(entries[2].old_path.as_deref(), Some("old.txt"));
assert_eq!((entries[2].added, entries[2].removed), (2, 0));
}
#[tokio::test]
async fn test_run_git_list_branches_single() {
let (_dir, repo_path) = init_temp_repo();
let out = run_git_command(&repo_path, &["branch", "--format=%(refname:short)"])
.await
.expect("list branches");
let branches: Vec<String> = out.lines().map(ToString::to_string).collect();
assert_eq!(branches.len(), 1, "single branch in new repo");
}
#[tokio::test]
async fn test_run_git_switch_and_create_branch() {
let (_dir, repo_path) = init_temp_repo();
let default_branch = run_git_current_branch(&repo_path)
.await
.expect("current branch");
run_git_command(&repo_path, &["switch", "-c", "feature/test"])
.await
.expect("create branch");
let current = run_git_current_branch(&repo_path)
.await
.expect("current branch");
assert_eq!(current, "feature/test");
let out = run_git_command(&repo_path, &["branch", "--format=%(refname:short)"])
.await
.expect("list branches");
let branches: Vec<String> = out.lines().map(ToString::to_string).collect();
assert!(branches.contains(&"feature/test".to_string()));
run_git_command(&repo_path, &["switch", default_branch.as_str()])
.await
.expect("switch back");
let switched = run_git_current_branch(&repo_path)
.await
.expect("current branch");
assert_eq!(switched, default_branch, "should be back on default branch");
}
#[tokio::test]
async fn test_run_git_commit_message() {
let (_dir, repo_path) = init_temp_repo();
let msg = run_git_commit_message(&repo_path, None)
.await
.expect("commit message without hash");
assert_eq!(msg, "Initial commit");
std::fs::write(repo_path.join("test.txt"), b"line1\nline2\n").expect("write test file");
let status = std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(&repo_path)
.status()
.expect("git add");
assert!(status.success());
let status = std::process::Command::new("git")
.args(["commit", "-m", "Second commit"])
.current_dir(&repo_path)
.status()
.expect("git commit");
assert!(status.success());
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&repo_path)
.output()
.expect("git rev-parse");
let second_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
let msg = run_git_commit_message(&repo_path, Some(&second_hash))
.await
.expect("commit message with hash");
assert_eq!(msg, "Second commit");
let msg = run_git_commit_message(&repo_path, None)
.await
.expect("commit message without hash");
assert_eq!(msg, "Second commit");
}
fn add_bare_remote(root: &Path, repo_path: &Path, name: &str) -> std::path::PathBuf {
let bare = root.join(format!("{name}.git"));
let status = std::process::Command::new("git")
.args(["init", "--bare"])
.arg(&bare)
.status()
.expect("git init --bare");
assert!(status.success());
let status = std::process::Command::new("git")
.args(["remote", "add", name])
.arg(&bare)
.current_dir(repo_path)
.status()
.expect("git remote add");
assert!(status.success());
bare
}
fn git_ok(repo_path: &Path, args: &[&str]) -> std::process::Output {
let output = std::process::Command::new("git")
.args(args)
.current_dir(repo_path)
.output()
.expect("run git");
assert!(
output.status.success(),
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
output
}
fn rev_parse(repo_path: &Path, rev: &str) -> String {
String::from_utf8_lossy(&git_ok(repo_path, &["rev-parse", rev]).stdout)
.trim()
.to_string()
}
fn commit_file(repo_path: &Path, name: &str, message: &str) {
std::fs::write(repo_path.join(name), b"new\n").expect("write");
git_ok(repo_path, &["add", "-A"]);
git_ok(repo_path, &["commit", "-m", message]);
}
async fn tracked(repo_path: &Path, branch: &str) -> Option<String> {
branch_remotes(repo_path, branch)
.await
.expect("branch remotes")
.upstream
}
async fn remote_state(repo_path: &Path) -> GitRemoteState {
run_git_remote_state(repo_path)
.await
.expect("remote state")
.expect("a branch is checked out")
}
#[tokio::test]
async fn publish_untracked_branch_to_single_pushable_remote() {
let (_dir, repo_path) = init_temp_repo();
let remotes = tempfile::tempdir().expect("remote dir");
let origin = add_bare_remote(remotes.path(), &repo_path, "origin");
let branch = run_git_current_branch(&repo_path).await.expect("branch");
let carried = count_commits(&repo_path, "HEAD").await.expect("count");
let state = remote_state(&repo_path).await;
assert_eq!(state.behind, 0);
let GitPushDestination::Target(target) = &state.push else {
panic!("expected a push target, got {:?}", state.push);
};
assert_eq!(target.remote, "origin");
assert_eq!(target.commits, carried, "publish carries every commit");
assert_eq!(target.publish, Some(true), "origin has no such branch yet");
assert!(state.has_sync_work());
let report = run_git_sync(&repo_path).await.expect("sync");
assert!(report.not_pushed.is_none());
assert!(
!report.output.trim().is_empty(),
"a real push must report something"
);
assert!(
report.output.contains(&branch),
"the report names the branch: {}",
report.output
);
git_ok(
&origin,
&["rev-parse", "--verify", &format!("refs/heads/{branch}")],
);
assert_eq!(
tracked(&repo_path, &branch).await.as_deref(),
Some("origin"),
"the publish records the upstream"
);
assert!(
!remote_state(&repo_path).await.has_sync_work(),
"in sync again: nothing left to offer"
);
commit_file(&repo_path, "after.txt", "after the publish");
let state = remote_state(&repo_path).await;
let GitPushDestination::Target(target) = &state.push else {
panic!("expected a push target, got {:?}", state.push);
};
assert_eq!(target.remote, "origin");
assert_eq!(target.publish, Some(false));
assert_eq!(target.commits, 1);
let report = run_git_sync(&repo_path).await.expect("sync");
assert!(report.not_pushed.is_none());
assert_eq!(
rev_parse(&origin, &format!("refs/heads/{branch}")),
rev_parse(&repo_path, "HEAD")
);
}
#[tokio::test]
async fn fetch_only_upstream_stays_tracked_while_pushing_to_origin() {
let (_dir, repo_path) = init_temp_repo();
let remotes = tempfile::tempdir().expect("remote dir");
add_bare_remote(remotes.path(), &repo_path, "upstream");
let branch = run_git_current_branch(&repo_path).await.expect("branch");
git_ok(&repo_path, &["push", "-u", "upstream", &branch]);
git_ok(
&repo_path,
&["remote", "set-url", "--push", "upstream", "no-push"],
);
let origin = add_bare_remote(remotes.path(), &repo_path, "origin");
git_ok(&repo_path, &["push", "origin", &branch]);
commit_file(&repo_path, "pushed.txt", "Only origin lacks this");
let state = remote_state(&repo_path).await;
let GitPushDestination::Target(target) = &state.push else {
panic!("expected a push target, got {:?}", state.push);
};
assert_eq!(target.remote, "origin");
assert_eq!(
target.publish,
Some(false),
"origin already carries the branch"
);
assert_eq!(target.commits, 1);
let report = run_git_sync(&repo_path).await.expect("sync");
assert!(report.not_pushed.is_none(), "origin accepts pushes");
assert_eq!(
tracked(&repo_path, &branch).await.as_deref(),
Some("upstream"),
"a fetch-only tracking is left alone"
);
assert_eq!(
rev_parse(&origin, &format!("refs/heads/{branch}")),
rev_parse(&repo_path, "HEAD"),
"origin received the commit"
);
}
#[tokio::test]
async fn update_and_push_counts_the_commits_a_press_pushes() {
let (_dir, repo_path) = init_temp_repo();
let remotes = tempfile::tempdir().expect("remote dir");
add_bare_remote(remotes.path(), &repo_path, "upstream");
let branch = run_git_current_branch(&repo_path).await.expect("branch");
git_ok(&repo_path, &["push", "-u", "upstream", &branch]);
let origin = add_bare_remote(remotes.path(), &repo_path, "origin");
git_ok(&repo_path, &["push", "origin", &branch]);
git_ok(&repo_path, &["switch", "-c", "upstream-side"]);
commit_file(&repo_path, "moved.txt", "upstream moved on");
commit_file(&repo_path, "moved-again.txt", "upstream moved on again");
git_ok(&repo_path, &["push", "upstream", &format!("HEAD:{branch}")]);
git_ok(&repo_path, &["switch", &branch]);
git_ok(
&repo_path,
&["remote", "set-url", "--push", "upstream", "no-push"],
);
git_ok(&repo_path, &["fetch", "upstream"]);
let state = remote_state(&repo_path).await;
assert_eq!(state.behind, 2);
let GitPushDestination::Target(target) = &state.push else {
panic!("expected a push target, got {:?}", state.push);
};
assert_eq!(target.remote, "origin");
assert_eq!(target.publish, Some(false));
assert_eq!(
target.commits, 2,
"the commits the fast-forward brings are pushed too"
);
let report = run_git_sync(&repo_path).await.expect("sync");
assert!(report.not_pushed.is_none());
assert_eq!(
rev_parse(&origin, &format!("refs/heads/{branch}")),
rev_parse(&repo_path, "HEAD"),
"origin received the fast-forwarded branch"
);
}
#[tokio::test]
async fn destination_advanced_elsewhere_is_fetched_before_counting() {
let (_dir, repo_path) = init_temp_repo();
let remotes = tempfile::tempdir().expect("remote dir");
let origin = add_bare_remote(remotes.path(), &repo_path, "origin");
let branch = run_git_current_branch(&repo_path).await.expect("branch");
git_ok(&repo_path, &["push", "origin", &branch]);
let elsewhere = remotes.path().join("elsewhere");
git_ok(
remotes.path(),
&[
"clone",
"--quiet",
origin.to_str().expect("origin path"),
elsewhere.to_str().expect("elsewhere path"),
],
);
for (key, value) in [("user.name", "Elsewhere"), ("user.email", "e@test.com")] {
git_ok(&elsewhere, &["config", key, value]);
}
git_ok(
&elsewhere,
&["checkout", "-B", &branch, &format!("origin/{branch}")],
);
commit_file(&elsewhere, "elsewhere.txt", "origin moved on");
git_ok(&elsewhere, &["push", "origin", &format!("HEAD:{branch}")]);
let elsewhere_head = rev_parse(&elsewhere, "HEAD");
commit_file(&repo_path, "local.txt", "only the local branch has this");
assert!(
!rev_exists(&repo_path, &elsewhere_head)
.await
.expect("rev exists"),
"the destination's new tip starts out unknown here"
);
let state = remote_state(&repo_path).await;
let GitPushDestination::Target(target) = &state.push else {
panic!("expected a push target, got {:?}", state.push);
};
assert_eq!(target.remote, "origin");
assert_eq!(target.publish, Some(false));
assert_eq!(target.commits, 1, "only the local commit is not on origin");
assert_eq!(
rev_parse(&repo_path, &format!("refs/remotes/origin/{branch}")),
elsewhere_head,
"the probe fetched the destination's copy"
);
}
#[tokio::test]
async fn destination_branch_is_detected_without_a_local_ref() {
let (_dir, repo_path) = init_temp_repo();
let remotes = tempfile::tempdir().expect("remote dir");
let origin = add_bare_remote(remotes.path(), &repo_path, "origin");
let branch = run_git_current_branch(&repo_path).await.expect("branch");
git_ok(&repo_path, &["push", "origin", &branch]);
git_ok(
&repo_path,
&["update-ref", "-d", &format!("refs/remotes/origin/{branch}")],
);
commit_file(&repo_path, "later.txt", "only the local branch has this");
let state = remote_state(&repo_path).await;
let GitPushDestination::Target(target) = &state.push else {
panic!("expected a push target, got {:?}", state.push);
};
assert_eq!(
target.publish,
Some(false),
"origin carries the branch even though no local ref says so"
);
assert_eq!(target.commits, 1);
let report = run_git_sync(&repo_path).await.expect("sync");
assert!(report.not_pushed.is_none());
assert_eq!(
tracked(&repo_path, &branch).await.as_deref(),
Some("origin")
);
assert_eq!(
rev_parse(&origin, &format!("refs/heads/{branch}")),
rev_parse(&repo_path, "HEAD")
);
}
#[tokio::test]
async fn nested_destination_branch_is_not_mistaken_for_ours() {
let (_dir, repo_path) = init_temp_repo();
let remotes = tempfile::tempdir().expect("remote dir");
add_bare_remote(remotes.path(), &repo_path, "origin");
let branch = run_git_current_branch(&repo_path).await.expect("branch");
git_ok(
&repo_path,
&[
"push",
"origin",
&format!("HEAD:refs/heads/feature/{branch}"),
],
);
let state = remote_state(&repo_path).await;
let GitPushDestination::Target(target) = &state.push else {
panic!("expected a push target, got {:?}", state.push);
};
assert_eq!(
target.publish,
Some(true),
"origin has no branch named {branch}"
);
assert_eq!(
target.commits,
count_commits(&repo_path, "HEAD").await.expect("count")
);
}
#[tokio::test]
async fn publish_skips_the_update_step() {
let (_dir, repo_path) = init_temp_repo();
let remotes = tempfile::tempdir().expect("remote dir");
add_bare_remote(remotes.path(), &repo_path, "upstream");
let branch = run_git_current_branch(&repo_path).await.expect("branch");
git_ok(&repo_path, &["push", "-u", "upstream", &branch]);
git_ok(&repo_path, &["switch", "-c", "upstream-side"]);
commit_file(&repo_path, "remote.txt", "upstream moved on");
git_ok(&repo_path, &["push", "upstream", &format!("HEAD:{branch}")]);
git_ok(&repo_path, &["switch", &branch]);
git_ok(
&repo_path,
&["remote", "set-url", "--push", "upstream", "no-push"],
);
let origin = add_bare_remote(remotes.path(), &repo_path, "origin");
git_ok(&repo_path, &["fetch", "upstream"]);
let published_head = rev_parse(&repo_path, "HEAD");
let state = remote_state(&repo_path).await;
assert_eq!(state.behind, 1);
let GitPushDestination::Target(target) = &state.push else {
panic!("expected a push target, got {:?}", state.push);
};
assert_eq!(target.publish, Some(true), "origin has no such branch yet");
let report = run_git_sync(&repo_path).await.expect("sync");
assert!(report.not_pushed.is_none());
assert_eq!(
rev_parse(&repo_path, "HEAD"),
published_head,
"a publish does not update the branch"
);
assert_eq!(
rev_parse(&origin, &format!("refs/heads/{branch}")),
published_head,
"origin received exactly what the branch carried"
);
assert_eq!(
tracked(&repo_path, &branch).await.as_deref(),
Some("upstream"),
"the fetch-only tracking is left alone"
);
}
#[tokio::test]
async fn tracked_push_capable_remote_stays_the_destination() {
let (_dir, repo_path) = init_temp_repo();
let remotes = tempfile::tempdir().expect("remote dir");
let upstream = add_bare_remote(remotes.path(), &repo_path, "upstream");
let branch = run_git_current_branch(&repo_path).await.expect("branch");
git_ok(&repo_path, &["push", "-u", "upstream", &branch]);
commit_file(&repo_path, "pushed.txt", "second");
let state = remote_state(&repo_path).await;
let GitPushDestination::Target(target) = &state.push else {
panic!("expected a push target, got {:?}", state.push);
};
assert_eq!(target.remote, "upstream");
assert_eq!(target.publish, Some(false));
assert_eq!(target.commits, 1);
let report = run_git_sync(&repo_path).await.expect("sync");
assert!(report.not_pushed.is_none());
assert!(
report.output.contains(&branch),
"a real push reports itself: {}",
report.output
);
assert_eq!(
rev_parse(&upstream, &format!("refs/heads/{branch}")),
rev_parse(&repo_path, "HEAD"),
"the tracked remote received the commit"
);
assert!(
!remote_state(&repo_path).await.has_sync_work(),
"in sync: the control hides, as before"
);
}
#[tokio::test]
async fn unreachable_destination_still_offers_a_push() {
let (_dir, repo_path) = init_temp_repo();
let remotes = tempfile::tempdir().expect("remote dir");
let origin = add_bare_remote(remotes.path(), &repo_path, "origin");
std::fs::remove_dir_all(&origin).expect("remove the remote");
let state = remote_state(&repo_path).await;
let GitPushDestination::Target(target) = &state.push else {
panic!("expected a push target, got {:?}", state.push);
};
assert_eq!(target.remote, "origin");
assert_eq!(target.publish, None, "the destination was never asked");
assert_eq!(
target.commits,
count_commits(&repo_path, "HEAD").await.expect("count"),
"the branch is reported with the commits it carries"
);
assert!(state.has_sync_work());
}
#[tokio::test]
async fn configured_push_remote_is_the_named_destination() {
let (_dir, repo_path) = init_temp_repo();
let remotes = tempfile::tempdir().expect("remote dir");
add_bare_remote(remotes.path(), &repo_path, "origin");
let backup = add_bare_remote(remotes.path(), &repo_path, "backup");
let branch = run_git_current_branch(&repo_path).await.expect("branch");
git_ok(&repo_path, &["push", "-u", "origin", &branch]);
git_ok(&repo_path, &["push", "backup", &branch]);
git_ok(
&repo_path,
&["config", &format!("branch.{branch}.pushRemote"), "backup"],
);
commit_file(&repo_path, "pushed.txt", "only the push remote lacks this");
let state = remote_state(&repo_path).await;
let GitPushDestination::Target(target) = &state.push else {
panic!("expected a push target, got {:?}", state.push);
};
assert_eq!(target.remote, "backup", "the control names the push remote");
assert_eq!(target.publish, Some(false));
assert_eq!(target.commits, 1);
let report = run_git_sync(&repo_path).await.expect("sync");
assert!(report.not_pushed.is_none());
assert_eq!(
rev_parse(&backup, &format!("refs/heads/{branch}")),
rev_parse(&repo_path, "HEAD"),
"the named destination received the commit"
);
assert_eq!(
tracked(&repo_path, &branch).await.as_deref(),
Some("origin"),
"the tracking is left alone"
);
}
#[tokio::test]
async fn fetch_only_push_remote_falls_back_to_the_tracked_remote() {
let (_dir, repo_path) = init_temp_repo();
let remotes = tempfile::tempdir().expect("remote dir");
let origin = add_bare_remote(remotes.path(), &repo_path, "origin");
let upstream = add_bare_remote(remotes.path(), &repo_path, "upstream");
let branch = run_git_current_branch(&repo_path).await.expect("branch");
git_ok(&repo_path, &["push", "-u", "origin", &branch]);
git_ok(
&repo_path,
&["remote", "set-url", "--push", "upstream", "no-push"],
);
git_ok(
&repo_path,
&["config", &format!("branch.{branch}.pushRemote"), "upstream"],
);
commit_file(
&repo_path,
"pushed.txt",
"only the tracked remote lacks this",
);
let state = remote_state(&repo_path).await;
let GitPushDestination::Target(target) = &state.push else {
panic!("expected a push target, got {:?}", state.push);
};
assert_eq!(target.remote, "origin");
assert_eq!(target.commits, 1);
let report = run_git_sync(&repo_path).await.expect("sync");
assert!(report.not_pushed.is_none());
assert_eq!(
rev_parse(&origin, &format!("refs/heads/{branch}")),
rev_parse(&repo_path, "HEAD")
);
let upstream_refs =
String::from_utf8_lossy(&git_ok(&upstream, &["for-each-ref"]).stdout).to_string();
assert!(
upstream_refs.trim().is_empty(),
"the fetch-only remote received nothing: {upstream_refs}"
);
}
#[tokio::test]
async fn missing_tracking_ref_still_resolves_and_publishes() {
let (_dir, repo_path) = init_temp_repo();
let remotes = tempfile::tempdir().expect("remote dir");
let origin = add_bare_remote(remotes.path(), &repo_path, "origin");
let branch = run_git_current_branch(&repo_path).await.expect("branch");
git_ok(&repo_path, &["push", "-u", "origin", &branch]);
git_ok(&origin, &["config", "receive.denyDeleteCurrent", "ignore"]);
git_ok(&repo_path, &["push", "origin", "--delete", &branch]);
let state = remote_state(&repo_path).await;
let GitPushDestination::Target(target) = &state.push else {
panic!("expected a push target, got {:?}", state.push);
};
assert_eq!(target.remote, "origin");
assert_eq!(
target.publish,
Some(true),
"origin no longer carries the branch"
);
let report = run_git_sync(&repo_path).await.expect("sync");
assert!(report.not_pushed.is_none());
assert_eq!(
rev_parse(&origin, &format!("refs/heads/{branch}")),
rev_parse(&repo_path, "HEAD")
);
assert_eq!(
tracked(&repo_path, &branch).await.as_deref(),
Some("origin"),
"the existing tracking config is left alone"
);
}
#[tokio::test]
async fn behind_branch_still_fast_forwards_when_nowhere_to_push() {
let (_dir, repo_path) = init_temp_repo();
let remotes = tempfile::tempdir().expect("remote dir");
add_bare_remote(remotes.path(), &repo_path, "upstream");
let branch = run_git_current_branch(&repo_path).await.expect("branch");
git_ok(&repo_path, &["push", "-u", "upstream", &branch]);
add_bare_remote(remotes.path(), &repo_path, "origin");
add_bare_remote(remotes.path(), &repo_path, "backup");
git_ok(&repo_path, &["switch", "-c", "upstream-side"]);
commit_file(&repo_path, "remote.txt", "upstream moved on");
git_ok(&repo_path, &["push", "upstream", &format!("HEAD:{branch}")]);
git_ok(&repo_path, &["switch", &branch]);
git_ok(
&repo_path,
&["remote", "set-url", "--push", "upstream", "no-push"],
);
git_ok(&repo_path, &["fetch", "upstream"]);
let behind_head = rev_parse(&repo_path, "HEAD");
let state = remote_state(&repo_path).await;
assert_eq!(state.behind, 1);
assert!(matches!(state.push, GitPushDestination::Nowhere { .. }));
assert!(state.has_sync_work());
let report = run_git_sync(&repo_path).await.expect("sync");
assert!(
report.not_pushed.is_some(),
"nothing can take a push, and the report says so"
);
assert_ne!(
rev_parse(&repo_path, "HEAD"),
behind_head,
"the branch was fast-forwarded from its own update source"
);
assert_eq!(
rev_parse(&repo_path, "HEAD"),
rev_parse(&repo_path, &format!("upstream/{branch}"))
);
}
#[tokio::test]
async fn no_pushable_remote_reports_nowhere() {
let (_dir, repo_path) = init_temp_repo();
let state = remote_state(&repo_path).await;
let GitPushDestination::Nowhere { reason } = &state.push else {
panic!("expected nowhere, got {:?}", state.push);
};
assert!(reason.contains("push"), "reason mentions pushes: {reason}");
assert!(
state.has_sync_work(),
"the control says why rather than hiding"
);
let report = run_git_sync(&repo_path).await.expect("sync");
assert_eq!(report.not_pushed.as_deref(), Some(reason.as_str()));
assert!(report.output.is_empty(), "nothing ran: {}", report.output);
}
#[tokio::test]
async fn several_pushable_remotes_are_not_guessed() {
let (_dir, repo_path) = init_temp_repo();
let remotes = tempfile::tempdir().expect("remote dir");
let origin = add_bare_remote(remotes.path(), &repo_path, "origin");
let backup = add_bare_remote(remotes.path(), &repo_path, "backup");
let state = remote_state(&repo_path).await;
let GitPushDestination::Nowhere { reason } = &state.push else {
panic!("expected nowhere, got {:?}", state.push);
};
assert!(
reason.contains("origin") && reason.contains("backup"),
"the reason names both remotes: {reason}"
);
assert!(
state.has_sync_work(),
"a branch with no single destination still shows the control"
);
let report = run_git_sync(&repo_path).await.expect("sync");
assert_eq!(report.not_pushed.as_deref(), Some(reason.as_str()));
assert!(report.output.is_empty(), "nothing ran: {}", report.output);
for remote in [&origin, &backup] {
let refs =
String::from_utf8_lossy(&git_ok(remote, &["for-each-ref"]).stdout).to_string();
assert!(refs.trim().is_empty(), "nothing was pushed: {refs}");
}
}
#[test]
fn push_destination_accepts_pushes_distinguishes_dummy_urls() {
let (_dir, repo_path) = init_temp_repo();
for url in ["no-push", "no_push", "DISABLED", "", " "] {
assert!(
!push_destination_accepts_pushes(&repo_path, url),
"{url:?} must not accept pushes"
);
}
for url in [
"../remote.git",
"/abs/x.git",
"https://example.com/x.git",
"git@example.com:x.git",
"ssh://example.com/x.git",
"ssh+git://example.com/x.git",
"git+ssh://example.com/x.git",
"codecommit://example/repo",
"file:///tmp/x.git",
] {
assert!(
push_destination_accepts_pushes(&repo_path, url),
"{url} must accept pushes"
);
}
std::fs::create_dir(repo_path.join("local-remote.git")).expect("create dir");
assert!(push_destination_accepts_pushes(
&repo_path,
"local-remote.git"
));
}
#[tokio::test]
async fn refused_push_reports_git_reason() {
let (_dir, repo_path) = init_temp_repo();
let remotes = tempfile::tempdir().expect("remote dir");
add_bare_remote(remotes.path(), &repo_path, "origin");
let branch = run_git_current_branch(&repo_path).await.expect("branch");
git_ok(&repo_path, &["push", "origin", &branch]);
git_ok(&repo_path, &["switch", "-c", "remote-only"]);
commit_file(&repo_path, "remote.txt", "remote only");
git_ok(&repo_path, &["push", "origin", &format!("HEAD:{branch}")]);
git_ok(&repo_path, &["switch", &branch]);
commit_file(&repo_path, "local.txt", "local only");
let err = run_git_sync(&repo_path)
.await
.expect_err("a diverged push must be refused")
.to_string();
assert!(
err.contains("rejected") || err.contains("non-fast-forward"),
"git's own refusal reason must survive: {err}"
);
}
const PORCELAIN_INPUT: &str = "\
?? new_file.rs
M modified.rs
?? another_new.py
A staged_new.js
?? dir/untracked.txt
M working_tree_only.txt
?? temp.log
AM staged_then_modified.js
A working_tree_new.txt
?? \"file\\tname.rs\"
A \"staged\\\"file.js\"
?? \"file\\\\backslash.rs\"
";
#[test]
fn parse_new_files_from_porcelain_extracts_new_files() {
let porcelain = PORCELAIN_INPUT;
let files = parse_new_files_from_porcelain(porcelain);
assert_eq!(files.len(), 9);
assert!(files.contains(&"new_file.rs".to_string()));
assert!(files.contains(&"another_new.py".to_string()));
assert!(files.contains(&"staged_new.js".to_string()));
assert!(files.contains(&"dir/untracked.txt".to_string()));
assert!(files.contains(&"temp.log".to_string()));
assert!(files.contains(&"staged_then_modified.js".to_string()));
assert!(files.contains(&"file\tname.rs".to_string()));
assert!(files.contains(&"staged\"file.js".to_string()));
assert!(files.contains(&"file\\backslash.rs".to_string()));
assert!(!files.contains(&"modified.rs".to_string()));
assert!(!files.contains(&"working_tree_only.txt".to_string()));
assert!(!files.contains(&"working_tree_new.txt".to_string()));
}
#[test]
fn parse_new_files_from_porcelain_returns_empty() {
let porcelain = "\
M modified.rs
M working_tree_only.txt
D deleted.rs
A working_tree_new.txt
";
let files = parse_new_files_from_porcelain(porcelain);
assert!(
files.is_empty(),
"Should be empty when no new/untracked files"
);
let short_lines = ["A", "A ", "?? ", "??"];
for &bad_line in &short_lines {
let files = parse_new_files_from_porcelain(bad_line);
assert!(
files.is_empty(),
"Malformed line {bad_line:?} should produce empty result, got {files:?}"
);
}
for &bad_line in &short_lines {
let files = parse_untracked_from_porcelain(bad_line);
assert!(
files.is_empty(),
"Malformed line {bad_line:?} should produce empty result from ??-only parser, got {files:?}"
);
}
}
#[test]
fn parse_porcelain_paths_skip_malformed_quoted_paths() {
let porcelain = "?? \"broken\\q.rs\"\n?? good.rs\nA \"also\\qbroken.rs\"\n";
assert_eq!(parse_untracked_from_porcelain(porcelain), vec!["good.rs"]);
assert_eq!(parse_new_files_from_porcelain(porcelain), vec!["good.rs"]);
}
#[test]
fn parse_untracked_from_porcelain_returns_only_untracked() {
let porcelain = PORCELAIN_INPUT;
let files = parse_untracked_from_porcelain(porcelain);
assert_eq!(files.len(), 6);
assert!(files.contains(&"new_file.rs".to_string()));
assert!(files.contains(&"another_new.py".to_string()));
assert!(files.contains(&"dir/untracked.txt".to_string()));
assert!(files.contains(&"temp.log".to_string()));
assert!(files.contains(&"file\tname.rs".to_string()));
assert!(files.contains(&"file\\backslash.rs".to_string()));
assert!(!files.contains(&"staged_new.js".to_string()));
assert!(!files.contains(&"staged_then_modified.js".to_string()));
assert!(!files.contains(&"modified.rs".to_string()));
assert!(!files.contains(&"working_tree_only.txt".to_string()));
assert!(!files.contains(&"working_tree_new.txt".to_string()));
assert!(!files.contains(&"staged\"file.js".to_string()));
}
#[test]
fn parse_untracked_from_porcelain_no_untracked() {
let porcelain = "\
M modified.rs
A staged_new.js
M working_tree_only.txt
AM staged_then_modified.js
A working_tree_new.txt
";
let files = parse_untracked_from_porcelain(porcelain);
assert!(
files.is_empty(),
"Should be empty when no `?? ` entries present"
);
}
#[test]
fn parse_untracked_from_porcelain_skips_collapsed_directories() {
let files = parse_untracked_from_porcelain("?? nested/\n?? real.rs\n");
assert_eq!(files, vec!["real.rs"]);
}
#[expect(clippy::too_many_lines)]
#[test]
fn test_parse_git_status_porcelain() {
struct Case {
name: &'static str,
input: &'static str,
expected: &'static [(&'static str, Option<GitFileStatus>)],
}
let cases: &[Case] = &[
Case {
name: "unstaged modified file",
input: " M src/main.rs\n",
expected: &[("src/main.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "staged added file",
input: "A new_file.rs\n",
expected: &[("new_file.rs", Some(GitFileStatus::Added))],
},
Case {
name: "untracked file",
input: "?? new_file.rs\n",
expected: &[("new_file.rs", Some(GitFileStatus::Added))],
},
Case {
name: "intent-to-add ( A) is added",
input: " A intent.rs\n",
expected: &[("intent.rs", Some(GitFileStatus::Added))],
},
Case {
name: "staged and unstaged modified (MM)",
input: "MM both.rs\n",
expected: &[("both.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "staged added + unstaged modified (AM)",
input: "AM partial.rs\n",
expected: &[("partial.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "rename (old -> new)",
input: "R old.rs -> new.rs\n",
expected: &[("new.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "rename with arrow in old path",
input: "R \"old -> name.rs\" -> \"new -> name.rs\"\n",
expected: &[("new -> name.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "rename from a quoted source to a plain destination",
input: "R \"a b.txt\" -> plain.txt\n",
expected: &[("plain.txt", Some(GitFileStatus::Modified))],
},
Case {
name: "rename into a quoted destination containing the delimiter",
input: "R plain.txt -> \"new -> name.txt\"\n",
expected: &[("new -> name.txt", Some(GitFileStatus::Modified))],
},
Case {
name: "rename carried in the worktree column",
input: " R old.rs -> new.rs\n",
expected: &[("new.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "copy skipped",
input: "C old.rs -> copy.rs\n",
expected: &[],
},
Case {
name: "unmerged add/add (AA) is added",
input: "AA both.rs\n",
expected: &[("both.rs", Some(GitFileStatus::Added))],
},
Case {
name: "added then deleted (AD) skipped",
input: "AD gone.rs\n",
expected: &[],
},
Case {
name: "typechange and unmerged (UU) skipped",
input: " T link\nUU conflict.rs\n",
expected: &[],
},
Case {
name: "untracked directory (trailing slash stripped)",
input: "?? new_dir/\n",
expected: &[("new_dir", Some(GitFileStatus::Added))],
},
Case {
name: "quoted path with spaces",
input: " M \"path with spaces.rs\"\n",
expected: &[("path with spaces.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "deleted file (unstaged) skipped",
input: " D gone.rs\n",
expected: &[],
},
Case {
name: "deleted file (staged) skipped",
input: "D gone.rs\n",
expected: &[],
},
Case {
name: "clean (unrecognized status) not present",
input: " clean.rs\n",
expected: &[("clean.rs", None)],
},
Case {
name: "multiple entries same file — modified wins over added",
input: "A dup.rs\n M dup.rs\n",
expected: &[("dup.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "multiple entries same file — added sticks",
input: "?? dup.rs\nA dup.rs\n",
expected: &[("dup.rs", Some(GitFileStatus::Added))],
},
Case {
name: "empty output",
input: "",
expected: &[],
},
Case {
name: "mixed statuses",
input: concat!(
" M src/main.rs\n",
"?? new_file.rs\n",
"A staged.rs\n",
" D deleted.rs\n",
),
expected: &[
("src/main.rs", Some(GitFileStatus::Modified)),
("new_file.rs", Some(GitFileStatus::Added)),
("staged.rs", Some(GitFileStatus::Added)),
("deleted.rs", None),
],
},
];
for case in cases {
let map = parse_git_status_porcelain(case.input);
if case.expected.is_empty() {
assert!(
map.is_empty(),
"case '{}' (input={:?}): expected empty map, got {:#?}",
case.name,
case.input,
map
);
} else {
let expected_count = case.expected.iter().filter(|(_, s)| s.is_some()).count();
assert_eq!(
map.len(),
expected_count,
"case '{}' (input={:?}): map has unexpected entries",
case.name,
case.input,
);
for &(path, expected_status) in case.expected {
match expected_status {
Some(status) => {
assert_eq!(
map.get(path),
Some(&status),
"case '{}' (input={:?}): path={:?}",
case.name,
case.input,
path
);
}
None => {
assert!(
!map.contains_key(path),
"case '{}' (input={:?}): path={:?} should be absent, got {:?}",
case.name,
case.input,
path,
map.get(path)
);
}
}
}
}
}
}
#[tokio::test]
async fn git_status_rename_with_a_quoted_source_keys_its_destination() {
let (_dir, repo_path) = init_temp_repo();
run_git_command(&repo_path, &["config", "status.renames", "true"])
.await
.unwrap();
std::fs::write(repo_path.join("a b.txt"), b"moved\n").unwrap();
run_git_commit(&repo_path, "add").await.unwrap();
run_git_command(&repo_path, &["mv", "a b.txt", "plain.txt"])
.await
.unwrap();
let porcelain = run_git_status(&repo_path).await.unwrap();
assert!(
porcelain.starts_with("R \"a b.txt\" -> plain.txt"),
"git's quoting is what this pins, got {porcelain:?}"
);
let map = parse_git_status_porcelain(&porcelain);
assert_eq!(
map.get("plain.txt"),
Some(&GitFileStatus::Modified),
"got {map:?}"
);
assert!(!map.contains_key("a b.txt"), "got {map:?}");
}
#[cfg(unix)]
#[tokio::test]
async fn git_status_rename_into_a_name_with_the_delimiter_keys_its_destination() {
let (_dir, repo_path) = init_temp_repo();
run_git_command(&repo_path, &["config", "status.renames", "true"])
.await
.unwrap();
run_git_command(&repo_path, &["mv", "test.txt", "new -> name.txt"])
.await
.unwrap();
let porcelain = run_git_status(&repo_path).await.unwrap();
assert!(
porcelain.starts_with("R test.txt -> \"new -> name.txt\""),
"git's quoting is what this pins, got {porcelain:?}"
);
let map = parse_git_status_porcelain(&porcelain);
assert_eq!(
map.get("new -> name.txt"),
Some(&GitFileStatus::Modified),
"got {map:?}"
);
}
#[test]
fn parse_numstat_lines_cases() {
let cases: &[(&str, &str, Vec<NumstatEntry>)] = &[
(
"normal",
"10\t3\tsrc/main.rs\n0\t1\tsrc/lib.rs\n42\t7\tCargo.toml\n",
vec![
NumstatEntry {
additions: Some(10),
deletions: Some(3),
path: "src/main.rs".to_string(),
},
NumstatEntry {
additions: Some(0),
deletions: Some(1),
path: "src/lib.rs".to_string(),
},
NumstatEntry {
additions: Some(42),
deletions: Some(7),
path: "Cargo.toml".to_string(),
},
],
),
(
"binary",
"-\t-\timage.png\n42\t7\tsrc/main.rs\n",
vec![
NumstatEntry {
additions: None,
deletions: None,
path: "image.png".to_string(),
},
NumstatEntry {
additions: Some(42),
deletions: Some(7),
path: "src/main.rs".to_string(),
},
],
),
(
"skips_malformed",
"\n\n10\t3\tsrc/main.rs\n\t\t\nnot-enough-fields\n",
vec![NumstatEntry {
additions: Some(10),
deletions: Some(3),
path: "src/main.rs".to_string(),
}],
),
("empty_string", "", vec![]),
("empty_lines", "\n\n\n", vec![]),
];
for (name, input, expected) in cases {
assert_eq!(parse_numstat_lines(input), *expected, "case: {name}");
}
}
#[tokio::test]
async fn test_run_git_discard_modified_file() {
let (_dir, repo_path) = init_temp_repo();
std::fs::write(repo_path.join("test.txt"), b"modified content\n")
.expect("write modified file");
let status = run_git_command(&repo_path, &["status", "--porcelain", "test.txt"])
.await
.expect("status before discard");
assert!(
!status.trim().is_empty(),
"file should be dirty before discard"
);
run_git_discard(&repo_path, "test.txt", DiscardTarget::File)
.await
.expect("run_git_discard should succeed");
let status = run_git_command(&repo_path, &["status", "--porcelain", "test.txt"])
.await
.expect("status after discard");
assert!(
status.trim().is_empty(),
"file should be clean after discard"
);
let content = std::fs::read_to_string(repo_path.join("test.txt")).expect("read file");
assert_eq!(
content, "line1\nline2\nline3\n",
"content should be restored to HEAD"
);
}
#[tokio::test]
async fn test_run_git_discard_new_file() {
let (_dir, repo_path) = init_temp_repo();
let new_path = repo_path.join("new_file.rs");
std::fs::write(&new_path, b"fn new() {}").expect("write new file");
assert!(new_path.exists(), "new file should exist before discard");
run_git_discard(&repo_path, "new_file.rs", DiscardTarget::File)
.await
.expect("run_git_discard should succeed");
assert!(
!new_path.exists(),
"new file should be removed after discard"
);
}
#[tokio::test]
async fn test_run_git_discard_from_subdirectory_targets_the_root_path() {
let (_dir, repo_path) = init_temp_repo();
let sub = repo_path.join("sub");
std::fs::create_dir(&sub).expect("create subdir");
let nested = sub.join("test.txt");
std::fs::write(&nested, b"nested\n").expect("write nested file");
std::fs::write(repo_path.join("test.txt"), b"modified\n").expect("modify root file");
run_git_discard(&sub, "test.txt", DiscardTarget::File)
.await
.expect("run_git_discard should succeed");
assert_eq!(
std::fs::read_to_string(repo_path.join("test.txt")).expect("read root file"),
"line1\nline2\nline3\n",
"the root file is restored"
);
assert!(
nested.exists(),
"the same-named file under the workspace is untouched"
);
}
#[tokio::test]
async fn test_run_git_discard_directory() {
let (_dir, repo_path) = init_temp_repo();
let sub_dir = repo_path.join("subdir");
std::fs::create_dir(&sub_dir).expect("create subdir");
let sub_file = sub_dir.join("nested.rs");
std::fs::write(&sub_file, b"fn nested() {}").expect("write nested file");
assert!(sub_file.exists(), "nested file should exist before discard");
run_git_discard(&repo_path, "subdir", DiscardTarget::Directory)
.await
.expect("run_git_discard should succeed");
assert!(
!sub_dir.exists(),
"directory should be removed after discard"
);
}
#[tokio::test]
async fn test_run_git_discard_clean_file() {
let (_dir, repo_path) = init_temp_repo();
let result = run_git_discard(&repo_path, "test.txt", DiscardTarget::File).await;
assert!(result.is_ok(), "discarding a clean file should succeed");
}
#[tokio::test]
async fn status_readers_pin_the_untracked_mode() {
let (_dir, repo_path) = init_temp_repo();
std::fs::create_dir(repo_path.join("sub")).unwrap();
std::fs::write(repo_path.join("sub/file.txt"), b"new\n").unwrap();
let nested = repo_path.join("nested");
std::fs::create_dir(&nested).unwrap();
run_git_command(&nested, &["init", "-q"]).await.unwrap();
run_git_command(&repo_path, &["config", "status.showUntrackedFiles", "no"])
.await
.unwrap();
let porcelain = run_git_status(&repo_path).await.unwrap();
assert!(
porcelain.lines().any(|line| line == "?? sub/"),
"untracked files must stay visible with the owner's mode off:\n{porcelain}"
);
assert_eq!(
list_new_or_untracked_files(&repo_path).await.unwrap(),
["nested/", "sub/"],
"the listing must keep the collapsed directory entry, not read as empty"
);
let err = run_git_discard(&repo_path, "nested", DiscardTarget::Directory)
.await
.expect_err("a leftover must be reported, not passed off as a success");
assert!(
err.to_string().contains("Changes remain after discard"),
"unexpected error: {err}"
);
}
#[test]
fn has_unstaged_changes_cases() {
let cases: &[(&str, &str, bool)] = &[
("empty_string", "", false),
("empty_lines", "\n\n", false),
("fully_staged_modified", "M Cargo.toml\n", false),
("fully_staged_added", "A src/lib.rs\n", false),
(
"fully_staged_mixed",
"M Cargo.toml\nA src/lib.rs\nD old.rs\n",
false,
),
("unstaged_modified", " M src/lib.rs\n", true),
("unstaged_deleted", " D src/old.rs\n", true),
("dual_status_modified", "MM src/lib.rs\n", true),
("dual_status_added", "AM src/new.rs\n", true),
("dual_status_deleted", "MD src/old.rs\n", true),
("untracked_file", "?? new_file.rs\n", true),
("untracked_directory", "?? dir/untracked.txt\n", true),
(
"mixed_only_staged",
"M Cargo.toml\nA src/main.rs\n",
false,
),
(
"mixed_staged_and_unstaged",
"M Cargo.toml\n M src/main.rs\n?? new.rs\n",
true,
),
("trailing_newline_unstaged", " M file.rs\n", true),
("trailing_newline_staged", "M file.rs\n", false),
("unparseable_quoted_path", " M \"broken\\q.rs\"\n", true),
(
"unparseable_path_after_staged",
"M file.rs\n M \"broken\\q.rs\"\n",
true,
),
];
for (name, input, expected) in cases {
assert_eq!(has_unstaged_changes(input), *expected, "case: {name}");
}
}
}