crates/signaltty-server/src/git.rs
//! Lightweight Git context: root, name, branch, detached, dirty.
//! Explicit refreshes only — never aggressive polling. See docs §17.
use std::process::Command;
pub fn git_output(cwd: &str, args: &[&str]) -> std::io::Result<std::process::Output> {
Command::new("git").arg("-C").arg(cwd).args(args).output()
}
use signaltty_core::model::GitInfo;
fn git(cwd: &str, args: &[&str]) -> Option<String> {
let text = String::from_utf8(git_bytes(cwd, args)?).ok()?;
Some(text.strip_suffix('\n').unwrap_or(&text).to_owned())
}
fn git_bytes(cwd: &str, args: &[&str]) -> Option<Vec<u8>> {
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args(args)
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(out.stdout)
}
/// One changed file in a worktree diff. Untracked files carry no counts;
/// binary files carry zero counts with `binary` set.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct DiffFile {
pub path: String,
pub added: u64,
pub removed: u64,
pub untracked: bool,
pub binary: bool,
}
/// Per-directory rollup of tracked counts.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct DiffDir {
pub dir: String,
pub added: u64,
pub removed: u64,
}
/// Worktree-vs-HEAD diff as data (t3code `+N −N` language). Not
/// turn-scoped: git has no snapshot of turn start (see ADR-0011).
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct WorktreeDiff {
pub branch: Option<String>,
pub files: Vec<DiffFile>,
pub dirs: Vec<DiffDir>,
pub added: u64,
pub removed: u64,
}
/// `git diff --numstat HEAD` + untracked names, or `None` outside a repo.
/// On demand only — never polled, never snapshotted.
pub fn git_diff(cwd: &str) -> Option<WorktreeDiff> {
let root = git(cwd, &["rev-parse", "--show-toplevel"])?;
let cwd = root.as_str();
let branch = git(cwd, &["branch", "--show-current"]).filter(|b| !b.is_empty());
let base = git(cwd, &["rev-parse", "--verify", "HEAD"])
.or_else(|| git(cwd, &["hash-object", "-t", "tree", "--stdin"]))?;
let numstat = git_bytes(
cwd,
&[
"diff",
"--numstat",
"-z",
"--no-renames",
"--no-relative",
&base,
],
)?;
let mut files = Vec::new();
for record in numstat.split(|b| *b == 0).filter(|r| !r.is_empty()) {
let line = std::str::from_utf8(record).ok()?;
let mut parts = line.splitn(3, '\t');
let (added_s, removed_s, path) = match (parts.next(), parts.next(), parts.next()) {
(Some(a), Some(r), Some(p)) => (a, r, p),
_ => continue,
};
let binary = added_s == "-";
files.push(DiffFile {
path: path.to_string(),
added: added_s.parse().unwrap_or(0),
removed: removed_s.parse().unwrap_or(0),
untracked: false,
binary,
});
}
let status = git_bytes(
cwd,
&[
"ls-files",
"--others",
"--exclude-standard",
"--full-name",
"-z",
],
)
.unwrap_or_default();
for path in status.split(|b| *b == 0).filter(|r| !r.is_empty()) {
files.push(DiffFile {
path: std::str::from_utf8(path).ok()?.to_owned(),
added: 0,
removed: 0,
untracked: true,
binary: false,
});
}
files.sort_by(|a, b| a.path.cmp(&b.path));
let mut dir_sums: std::collections::BTreeMap<String, (u64, u64)> =
std::collections::BTreeMap::new();
let mut added = 0u64;
let mut removed = 0u64;
for f in &files {
if f.untracked {
continue;
}
added += f.added;
removed += f.removed;
let dir = std::path::Path::new(&f.path)
.parent()
.map(|p| {
let s = p.to_string_lossy().to_string();
if s.is_empty() {
".".to_string()
} else {
s
}
})
.unwrap_or_else(|| ".".to_string());
let entry = dir_sums.entry(dir).or_insert((0, 0));
entry.0 += f.added;
entry.1 += f.removed;
}
let dirs = dir_sums
.into_iter()
.map(|(dir, (added, removed))| DiffDir {
dir,
added,
removed,
})
.collect();
Some(WorktreeDiff {
branch,
files,
dirs,
added,
removed,
})
}
/// Diff task worktree vs recorded `base_sha`.
/// Tracked changes via `git diff --numstat base_sha` + untracked files via `ls-files`
/// with line count as additions.
/// Does NOT mutate git index (no `git add -N`).
pub fn task_git_diff(
worktree_path: &str,
base_sha: &str,
) -> Result<WorktreeDiff, (String, String)> {
let root = git(worktree_path, &["rev-parse", "--show-toplevel"]).ok_or_else(|| {
(
signaltty_proto::code::IO_ERROR.into(),
format!("not a git repo: {worktree_path}"),
)
})?;
let cwd = root.as_str();
let branch = git(cwd, &["branch", "--show-current"]).filter(|b| !b.is_empty());
let verify = git(cwd, &["rev-parse", "--verify", base_sha]).ok_or_else(|| {
(
signaltty_proto::code::IO_ERROR.into(),
format!("missing base commit: {base_sha}"),
)
})?;
let base = verify.trim();
let numstat = git_bytes(
cwd,
&[
"diff",
"--numstat",
"-z",
"--no-renames",
"--no-relative",
base,
],
)
.ok_or_else(|| {
(
signaltty_proto::code::IO_ERROR.into(),
"git diff failed".into(),
)
})?;
let mut files = Vec::new();
for record in numstat.split(|b| *b == 0).filter(|r| !r.is_empty()) {
let line = match std::str::from_utf8(record) {
Ok(l) => l,
Err(_) => continue,
};
let mut parts = line.splitn(3, '\t');
let (added_s, removed_s, path) = match (parts.next(), parts.next(), parts.next()) {
(Some(a), Some(r), Some(p)) => (a, r, p),
_ => continue,
};
let binary = added_s == "-";
files.push(DiffFile {
path: path.to_string(),
added: added_s.parse().unwrap_or(0),
removed: removed_s.parse().unwrap_or(0),
untracked: false,
binary,
});
}
let status = git_bytes(
cwd,
&[
"ls-files",
"--others",
"--exclude-standard",
"--full-name",
"-z",
],
)
.unwrap_or_default();
for path_bytes in status.split(|b| *b == 0).filter(|r| !r.is_empty()) {
let path_str = match std::str::from_utf8(path_bytes) {
Ok(p) => p,
Err(_) => continue,
};
let full_path = std::path::Path::new(cwd).join(path_str);
// Capped, non-following read. A symlink, fifo, or oversized file is
// binary with no line count so the runtime never loads the body.
let (added, binary) = untracked_line_stats(&full_path);
files.push(DiffFile {
path: path_str.to_string(),
added,
removed: 0,
untracked: true,
binary,
});
}
files.sort_by(|a, b| a.path.cmp(&b.path));
let mut dir_sums: std::collections::BTreeMap<String, (u64, u64)> =
std::collections::BTreeMap::new();
let mut added = 0u64;
let mut removed = 0u64;
for f in &files {
added += f.added;
removed += f.removed;
let dir = std::path::Path::new(&f.path)
.parent()
.map(|p| {
let s = p.to_string_lossy().to_string();
if s.is_empty() {
".".to_string()
} else {
s
}
})
.unwrap_or_else(|| ".".to_string());
let entry = dir_sums.entry(dir).or_insert((0, 0));
entry.0 += f.added;
entry.1 += f.removed;
}
let dirs = dir_sums
.into_iter()
.map(|(dir, (added, removed))| DiffDir {
dir,
added,
removed,
})
.collect();
Ok(WorktreeDiff {
branch,
files,
dirs,
added,
removed,
})
}
/// Line count for one untracked path. Never follows symlinks, never reads
/// past [`signaltty_core::diff::MAX_PREVIEW_BYTES`], and opens non-blocking
/// so a fifo cannot stall the caller. Anything that is not a small regular
/// text file is `(0, true)`.
fn untracked_line_stats(path: &std::path::Path) -> (u64, bool) {
use std::io::Read;
use std::os::fd::FromRawFd;
let bytes = match path.as_os_str().to_str() {
Some(s) => s.as_bytes(),
None => return (0, true),
};
let c_path = match std::ffi::CString::new(bytes) {
Ok(c) => c,
Err(_) => return (0, true),
};
let fd = unsafe {
nix::libc::open(
c_path.as_ptr(),
nix::libc::O_RDONLY
| nix::libc::O_NOFOLLOW
| nix::libc::O_NONBLOCK
| nix::libc::O_CLOEXEC,
)
};
if fd < 0 {
return (0, true);
}
let file = unsafe { std::fs::File::from_raw_fd(fd) };
let meta = match file.metadata() {
Ok(meta) => meta,
Err(_) => return (0, true),
};
let cap = signaltty_core::diff::MAX_PREVIEW_BYTES as u64;
if !meta.is_file() || meta.len() > cap {
return (0, true);
}
let mut body = Vec::new();
if file.take(cap + 1).read_to_end(&mut body).is_err() || body.len() as u64 > cap {
return (0, true);
}
if body.contains(&0) {
return (0, true);
}
if body.is_empty() {
return (0, false);
}
let mut lines = body.split(|b| *b == b'\n').count() as u64;
if body.ends_with(b"\n") {
lines = lines.saturating_sub(1);
}
(lines, false)
}
pub fn git_info(cwd: &str) -> GitInfo {
let root = git(cwd, &["rev-parse", "--show-toplevel"]);
if root.is_none() {
return GitInfo::default();
}
let root = root.unwrap();
let name = std::path::Path::new(&root)
.file_name()
.map(|n| n.to_string_lossy().to_string());
let branch = git(cwd, &["branch", "--show-current"]).filter(|b| !b.is_empty());
let detached = branch.is_none();
// Untracked excluded: cheap enough for explicit refresh.
let dirty =
git(cwd, &["status", "--porcelain=v1", "--untracked-files=no"]).map(|s| !s.is_empty());
GitInfo {
root: Some(root),
name,
branch,
detached,
dirty,
}
}
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
/// Strict porcelain cleanliness check: `Ok(true)` = clean tree,
/// `Ok(false)` = dirty tree. A git failure (spawn error or non-zero exit,
/// e.g. dubious ownership or a missing `.git` pointer) is `Err` — an empty
/// or missing stdout is never treated as clean.
pub fn porcelain_clean(cwd: &str, args: &[&str]) -> Result<bool, String> {
let out = git_output(cwd, args).map_err(|e| format!("git failed: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
let detail = if stderr.is_empty() {
String::new()
} else {
format!(": {stderr}")
};
return Err(format!(
"git {} exited {}{detail}",
args.join(" "),
out.status
));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().is_empty())
}
/// True when `path` is a registered worktree of `repo` (herdr leftover
/// guard: a leftover directory may be force-deleted only after git says it
/// is not a registered worktree). Fail-closed: when git itself fails, the
/// path counts as registered so callers never `remove_dir_all` blind.
pub fn is_worktree_registered(repo: &str, path: &str) -> bool {
let out = match git_output(repo, &["worktree", "list", "--porcelain"]) {
Ok(out) if out.status.success() => out,
_ => return true,
};
let stdout = String::from_utf8_lossy(&out.stdout);
let canon = std::path::Path::new(path)
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.ok();
stdout
.lines()
.filter_map(|line| line.strip_prefix("worktree "))
.any(|w| w == path || canon.as_deref().is_some_and(|c| w == c))
}
/// Resolve base_ref into a 40-character commit SHA. A best-effort
/// `git fetch origin` is the caller's job (bounded, async) — see `task.start`.
pub fn resolve_base_ref(repo: &str, base_ref: &str) -> Result<String, String> {
let ref_spec = format!("{base_ref}^{{commit}}");
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "--verify", &ref_spec])
.output()
.map_err(|e| format!("git rev-parse failed: {e}"))?;
if !out.status.success() {
return Err(format!(
"cannot resolve base_ref '{base_ref}' in repo '{repo}'"
));
}
let sha = String::from_utf8(out.stdout)
.map_err(|e| format!("invalid utf-8 in rev-parse output: {e}"))?
.trim()
.to_string();
if sha.len() != 40 {
return Err(format!("expected 40-char SHA, got '{sha}'"));
}
Ok(sha)
}
/// Detect the currently checked-out branch in repo.
/// Returns None if HEAD is detached or outside a branch.
pub fn detect_target_branch(repo: &str) -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["branch", "--show-current"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let branch = String::from_utf8(out.stdout).ok()?.trim().to_string();
if branch.is_empty() {
None
} else {
Some(branch)
}
}
/// Check if a local branch exists in the repository.
pub fn branch_exists(repo: &str, branch: &str) -> bool {
let ref_spec = format!("refs/heads/{branch}");
Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "--verify", &ref_spec])
.output()
.map(|out| out.status.success())
.unwrap_or(false)
}
/// Sanitize branch name for use as a directory component (slashes -> dashes, etc.).
pub fn sanitize_branch_for_path(branch: &str) -> String {
branch
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'-'
}
})
.collect()
}
/// Default task branch `signaltty/<label>-<short_id>`. The label is free
/// text, so on top of the path-safe characters a leading dot and `..` are
/// dropped: `git check-ref-format` rejects both, and a label like
/// `.github: fix CI` would otherwise fail `task.start` with no branch given.
pub fn default_task_branch(label: &str, short_id: &str) -> String {
let mut component = String::new();
for c in sanitize_branch_for_path(label).chars() {
if c == '.' && (component.is_empty() || component.ends_with('.')) {
continue;
}
component.push(c);
}
format!("signaltty/{component}-{short_id}")
}
/// Compute default worktree path under $XDG_DATA_HOME/signaltty/worktrees/<repo-name>-<short-hash>/<sanitized-branch>.
pub fn default_worktree_path(repo: &str, branch: &str) -> PathBuf {
let repo_path = Path::new(repo);
let canonical = repo_path
.canonicalize()
.unwrap_or_else(|_| repo_path.to_path_buf());
let repo_dirname = canonical
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("repo");
let mut hasher = DefaultHasher::new();
canonical.to_string_lossy().hash(&mut hasher);
let short_hash = format!("{:08x}", (hasher.finish() as u32));
let sanitized_branch = sanitize_branch_for_path(branch);
signaltty_core::paths::data_dir()
.join("worktrees")
.join(format!("{repo_dirname}-{short_hash}"))
.join(sanitized_branch)
}
/// Validate a branch name per `git check-ref-format --branch`.
/// Rejects empty string, leading '-', any name containing `refs/` or `@{`,
/// and any name whose `--branch` expansion differs from the input (e.g.
/// `@{-1}` expands to the previous branch; `refs/heads/<current>` would
/// otherwise create a nested ref whose commits a later merge silently drops).
/// Callers must still pass `--` before the branch in git invocations.
pub fn validate_branch_name(branch: &str) -> Result<(), String> {
if branch.is_empty() || branch.starts_with('-') {
return Err(format!(
"invalid branch name '{branch}': cannot start with '-' or be empty"
));
}
if branch.contains("refs/") || branch.contains("@{") {
return Err(format!(
"invalid branch name '{branch}': must not contain 'refs/' or '@{{'"
));
}
let out = Command::new("git")
.args(["check-ref-format", "--branch", branch])
.output()
.map_err(|e| format!("git check-ref-format failed: {e}"))?;
if !out.status.success() {
return Err(format!(
"invalid branch name '{branch}' per git check-ref-format"
));
}
let printed = String::from_utf8_lossy(&out.stdout).trim().to_string();
if printed != branch {
return Err(format!(
"invalid branch name '{branch}': git expands it to '{printed}'"
));
}
Ok(())
}
/// Would merging `branch` into `target` now conflict? From `git merge-tree
/// --write-tree` (git >= 2.38; touches no index or checkout): `(clean,
/// conflicted paths)`. Clean comes from the exit code, since some conflicts
/// (directory renames) name no file. `None` when git cannot tell.
pub fn merge_preview(repo: &str, target: &str, branch: &str) -> Option<(bool, Vec<String>)> {
let target = format!("refs/heads/{target}");
let branch = format!("refs/heads/{branch}");
let out = git_output(
repo,
&[
"merge-tree",
"--write-tree",
"--name-only",
"--no-messages",
"-z",
&target,
&branch,
],
)
.ok()?;
let clean = match out.status.code() {
Some(0) => true,
Some(1) => false,
_ => return None,
};
// NUL-separated: the result tree, then conflicted paths (unquoted).
let mut conflicted: Vec<String> = Vec::new();
for path in out
.stdout
.split(|b| *b == 0)
.skip(1)
.filter(|p| !p.is_empty())
{
let path = String::from_utf8_lossy(path).into_owned();
if !conflicted.contains(&path) {
conflicted.push(path);
}
}
Some((clean, conflicted))
}
/// True when `HEAD` of `repo` descends from `tip` (a full commit SHA).
/// Used after `task.finish --merge` to prove the task commits landed
/// before recording `merged`.
pub fn head_contains_branch_tip(repo: &str, tip: &str) -> bool {
Command::new("git")
.arg("-C")
.arg(repo)
.args(["merge-base", "--is-ancestor", tip, "HEAD"])
.output()
.map(|out| out.status.success())
.unwrap_or(false)
}
/// Validate a base ref format.
/// Rejects empty string, leading '-', or names invalid per git check-ref-format.
pub fn validate_base_ref_format(base_ref: &str) -> Result<(), String> {
if base_ref.is_empty() || base_ref.starts_with('-') {
return Err(format!(
"invalid base_ref '{base_ref}': cannot start with '-' or be empty"
));
}
// 40-character hex commit SHA is valid
if base_ref.len() == 40 && base_ref.chars().all(|c| c.is_ascii_hexdigit()) {
return Ok(());
}
let out = Command::new("git")
.args(["check-ref-format", "--allow-onelevel", base_ref])
.output()
.map_err(|e| format!("git check-ref-format failed: {e}"))?;
if !out.status.success() {
return Err(format!(
"invalid base_ref '{base_ref}' per git check-ref-format"
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_repo(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"signaltty-diff-{tag}-{}-{}",
std::process::id(),
signaltty_core::ids::new_pane_id()
));
std::fs::create_dir_all(dir.join("sub")).unwrap();
let run = |args: &[&str]| {
let out = Command::new("git")
.arg("-C")
.arg(&dir)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "{args:?}");
};
run(&["init", "-q"]);
run(&["config", "user.email", "t@t"]);
run(&["config", "user.name", "t"]);
run(&["config", "commit.gpgsign", "false"]);
std::fs::write(dir.join("a.txt"), "1\n2\n3\n").unwrap();
std::fs::write(dir.join("sub/b.txt"), "x\n").unwrap();
// Binary file: 8 bytes with a NUL.
std::fs::write(dir.join("bin.dat"), [0x00, 0x01, 0x02, 0x03]).unwrap();
run(&["add", "."]);
run(&["commit", "-qm", "base"]);
// Modify: +3/−1 on a.txt; append sub/b.txt (+1).
std::fs::write(dir.join("a.txt"), "1\n2 changed\n3\n4\n5\n").unwrap();
std::fs::write(dir.join("sub/b.txt"), "x\ny\n").unwrap();
std::fs::write(dir.join("bin.dat"), [0x00, 0x01, 0x02, 0x03, 0x04]).unwrap();
std::fs::write(dir.join("new.txt"), "untracked\n").unwrap();
dir
}
#[test]
fn diff_reports_numstat_untracked_and_binary() {
let dir = fixture_repo("basic");
let d = git_diff(dir.to_str().unwrap()).unwrap();
let file = |p: &str| d.files.iter().find(|f| f.path == p).unwrap().clone();
// a.txt: lines 2 changed (1 del + 1 add?) — assert against git itself.
let raw = Command::new("git")
.arg("-C")
.arg(&dir)
.args(["diff", "--numstat", "HEAD"])
.output()
.unwrap();
let text = String::from_utf8(raw.stdout).unwrap();
for line in text.lines() {
let mut parts = line.split('\t');
let (a, r, p) = (
parts.next().unwrap(),
parts.next().unwrap(),
parts.next().unwrap(),
);
if a == "-" {
let f = file(p);
assert!(f.binary && f.added == 0 && f.removed == 0, "{p}");
} else {
let f = file(p);
assert_eq!(
(f.added, f.removed),
(a.parse().unwrap(), r.parse().unwrap()),
"{p}"
);
}
}
let new = file("new.txt");
assert!(new.untracked && !new.binary);
assert!(file("bin.dat").binary);
// Dir rollup covers tracked counts only, sorted deterministically.
let total_dirs: (u64, u64) = d
.dirs
.iter()
.map(|g| (g.added, g.removed))
.fold((0, 0), |a, b| (a.0 + b.0, a.1 + b.1));
assert_eq!((d.added, d.removed), total_dirs);
assert!(d.dirs.windows(2).all(|w| w[0].dir <= w[1].dir));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn diff_outside_a_repo_is_none() {
assert_eq!(git_diff("/tmp"), None);
}
#[test]
fn diff_preserves_tabs_and_newlines_in_filenames() {
let dir = fixture_repo("names");
let tracked = "tracked\tfile\nname.txt";
let untracked = "new\tfile\nname.txt";
std::fs::write(dir.join(tracked), "old\n").unwrap();
for args in [
vec!["add", "--", tracked],
vec!["commit", "-qm", "unusual filename"],
] {
assert!(Command::new("git")
.arg("-C")
.arg(&dir)
.args(args)
.status()
.unwrap()
.success());
}
std::fs::write(dir.join(tracked), "new\nextra\n").unwrap();
std::fs::write(dir.join(untracked), "untracked\n").unwrap();
let diff = git_diff(dir.to_str().unwrap()).unwrap();
let file = diff
.files
.iter()
.find(|f| f.path == tracked)
.expect("literal tracked filename");
assert_eq!((file.added, file.removed), (2, 1));
assert!(diff
.files
.iter()
.any(|f| f.path == untracked && f.untracked));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn diff_in_unborn_repo_reports_staged_and_untracked_files() {
let dir = fixture_repo("unborn");
std::fs::remove_dir_all(dir.join(".git")).unwrap();
assert!(Command::new("git")
.arg("-C")
.arg(&dir)
.args(["init", "-q"])
.status()
.unwrap()
.success());
assert!(Command::new("git")
.arg("-C")
.arg(&dir)
.args(["add", "a.txt"])
.status()
.unwrap()
.success());
let diff = git_diff(dir.to_str().unwrap()).expect("unborn repo has a working-tree diff");
let staged = diff.files.iter().find(|f| f.path == "a.txt").unwrap();
assert_eq!((staged.added, staged.removed), (5, 0));
assert!(diff
.files
.iter()
.any(|f| f.path == "new.txt" && f.untracked));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn branch_validation_rejects_expanding_and_ref_paths() {
let dir = fixture_repo("branch-names");
let dir_str = dir.to_str().unwrap();
let current = detect_target_branch(dir_str).unwrap();
// Plain names pass.
assert!(validate_branch_name("feature/work").is_ok());
assert!(validate_branch_name(¤t).is_ok());
// `git check-ref-format --branch` exits 0 for these but must not
// be stored: refs/heads/<current> would create a nested ref whose
// commits a later merge (resolving to the checked-out branch)
// silently drops.
assert!(validate_branch_name(&format!("refs/heads/{current}")).is_err());
assert!(validate_branch_name("refs/heads/other").is_err());
assert!(validate_branch_name("task/refs/x").is_err());
// `@{-1}` expands via stdout; stdout must equal the input.
assert!(validate_branch_name("@{-1}").is_err());
assert!(validate_branch_name("foo@{").is_err());
assert!(validate_branch_name("foo@{u}").is_err());
assert!(validate_branch_name("").is_err());
assert!(validate_branch_name("-leading-dash").is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn head_descends_from_branch_tip_after_merge() {
let dir = fixture_repo("descendant");
let dir_str = dir.to_str().unwrap();
let run = |args: &[&str]| {
let out = Command::new("git")
.arg("-C")
.arg(&dir)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "{args:?}");
out
};
let base = detect_target_branch(dir_str).unwrap();
run(&["checkout", "-qb", "task-x"]);
std::fs::write(dir.join("t.txt"), "work\n").unwrap();
run(&["add", "."]);
run(&["commit", "-qm", "work"]);
let tip_out = run(&["rev-parse", "task-x"]);
let tip = String::from_utf8(tip_out.stdout)
.unwrap()
.trim()
.to_string();
run(&["checkout", "-q", &base]);
// Before merge the base HEAD does not contain the tip.
assert!(!head_contains_branch_tip(dir_str, &tip));
run(&["merge", "--no-ff", "--no-edit", "--", "task-x"]);
assert!(head_contains_branch_tip(dir_str, &tip));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn untracked_symlink_oversize_and_fifo_are_not_loaded() {
let dir = fixture_repo("untracked-cap");
let dir_str = dir.to_str().unwrap();
let head = String::from_utf8(
Command::new("git")
.args(["-C", dir_str, "rev-parse", "HEAD"])
.output()
.unwrap()
.stdout,
)
.unwrap();
let head = head.trim();
std::fs::write(dir.join("note.txt"), "a\nb\n").unwrap();
std::os::unix::fs::symlink("note.txt", dir.join("link.txt")).unwrap();
std::fs::write(dir.join("big.txt"), vec![b'a'; 512 * 1024 + 1]).unwrap();
// git ls-files does not list a fifo, but it does list a symlink to one.
// Following that symlink is what blocks `std::fs::read`.
let pipe = dir.join("pipe");
let c_pipe = std::ffi::CString::new(pipe.to_str().unwrap()).unwrap();
assert_eq!(unsafe { nix::libc::mkfifo(c_pipe.as_ptr(), 0o644) }, 0);
std::os::unix::fs::symlink("pipe", dir.join("pipe-link")).unwrap();
let started = std::time::Instant::now();
let direct = untracked_line_stats(&pipe);
assert_eq!(direct, (0, true), "fifo must not be read, got {direct:?}");
let diff = task_git_diff(dir_str, head).unwrap();
assert!(
started.elapsed() < std::time::Duration::from_secs(2),
"task diff blocked while classifying untracked files"
);
let file = |p: &str| {
diff.files
.iter()
.find(|f| f.path == p)
.unwrap_or_else(|| panic!("missing {p} in {diff:?}"))
.clone()
};
let note = file("note.txt");
assert!(note.untracked && !note.binary && note.added == 2);
let link = file("link.txt");
assert!(
link.untracked && link.binary && link.added == 0,
"symlink must not be followed, got {link:?}"
);
let big = file("big.txt");
assert!(
big.untracked && big.binary && big.added == 0,
"oversize untracked file must not be counted, got {big:?}"
);
let fifo = file("pipe-link");
assert!(
fifo.untracked && fifo.binary && fifo.added == 0,
"symlink to a fifo must not be followed, got {fifo:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn porcelain_failure_is_error_never_clean() {
// A failed `git status` (missing .git, dubious ownership, corrupt
// index) must surface as Err: empty stdout is not a clean tree.
let outside = std::env::temp_dir().join(format!(
"signaltty-norepo-{}-{}",
std::process::id(),
signaltty_core::ids::new_pane_id()
));
std::fs::create_dir_all(&outside).unwrap();
// A `.git` pointer to nowhere fails status even when this directory
// sits inside another repo (test TMPDIR often does). Empty stdout
// from that failure must not look clean.
std::fs::write(outside.join(".git"), "gitdir: /no/such/gitdir\n").unwrap();
let outside_str = outside.to_str().unwrap();
assert!(
porcelain_clean(outside_str, &["status", "--porcelain"]).is_err(),
"broken gitdir must not count as a clean tree"
);
let dir = fixture_repo("porcelain");
let dir_str = dir.to_str().unwrap();
// Fixture has modifications -> dirty.
assert_eq!(
porcelain_clean(dir_str, &["status", "--porcelain"]),
Ok(false)
);
std::fs::remove_dir_all(&dir).ok();
std::fs::remove_dir_all(&outside).ok();
}
#[test]
fn worktree_registration_guard() {
let dir = fixture_repo("registered");
let dir_str = dir.to_str().unwrap();
let wt = dir.join("wt-guard");
let wt_str = wt.to_str().unwrap().to_string();
assert!(!is_worktree_registered(dir_str, &wt_str));
let out = Command::new("git")
.arg("-C")
.arg(&dir)
.args(["worktree", "add", &wt_str])
.output()
.unwrap();
assert!(out.status.success());
assert!(is_worktree_registered(dir_str, &wt_str));
let out = Command::new("git")
.arg("-C")
.arg(&dir)
.args(["worktree", "remove", "--force", &wt_str])
.output()
.unwrap();
assert!(out.status.success());
assert!(!is_worktree_registered(dir_str, &wt_str));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn base_ref_resolution_and_branch_helpers() {
let dir = fixture_repo("helpers");
let dir_str = dir.to_str().unwrap();
// resolve_base_ref
let head_sha = resolve_base_ref(dir_str, "HEAD").unwrap();
assert_eq!(head_sha.len(), 40);
// bad base_ref
assert!(resolve_base_ref(dir_str, "no-such-ref-12345").is_err());
// branch_exists
let current_branch = detect_target_branch(dir_str).unwrap();
assert!(branch_exists(dir_str, ¤t_branch));
assert!(!branch_exists(dir_str, "nonexistent-branch"));
// sanitize_branch_for_path
assert_eq!(
sanitize_branch_for_path("feature/my-task:v1"),
"feature-my-task-v1"
);
// default_task_branch: free-text labels always give a valid branch
assert_eq!(
default_task_branch("task", "1a2b3c4d"),
"signaltty/task-1a2b3c4d"
);
assert_eq!(
default_task_branch(".github: fix CI", "1a2b3c4d"),
"signaltty/github--fix-CI-1a2b3c4d"
);
assert_eq!(
default_task_branch("v1..v2", "1a2b3c4d"),
"signaltty/v1.v2-1a2b3c4d"
);
for label in [
".github: fix CI",
"v1..v2",
"...",
".",
"a/../b",
"x.lock",
"",
] {
let branch = default_task_branch(label, "1a2b3c4d");
assert!(
validate_branch_name(&branch).is_ok(),
"label {label:?} gave invalid branch {branch:?}"
);
}
// default_worktree_path
let wt = default_worktree_path(dir_str, "feature/foo");
let wt_str = wt.to_string_lossy();
assert!(wt_str.contains("worktrees"));
assert!(wt_str.contains("feature-foo"));
std::fs::remove_dir_all(dir).ok();
}
}