use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use ratatui::style::Color;
#[cfg(all(test, feature = "git"))]
pub static STATUS_CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
#[cfg(feature = "git")]
thread_local! {
static EXTERNAL_GIT_ENABLED: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
}
#[cfg(feature = "git")]
pub fn set_external_git_enabled(enabled: bool) {
EXTERNAL_GIT_ENABLED.with(|c| c.set(enabled));
}
#[cfg(feature = "git")]
pub fn external_git_enabled() -> bool {
EXTERNAL_GIT_ENABLED.with(|c| c.get()) && git_binary_available()
}
#[cfg(feature = "git")]
static GIT_BINARY_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
#[cfg(all(test, feature = "git"))]
thread_local! {
static GIT_BINARY_AVAILABLE_OVERRIDE: std::cell::Cell<Option<bool>> =
const { std::cell::Cell::new(None) };
}
#[cfg(all(test, feature = "git"))]
pub fn set_git_binary_available_for_test(v: Option<bool>) {
GIT_BINARY_AVAILABLE_OVERRIDE.with(|c| c.set(v));
}
#[cfg(feature = "git")]
pub fn git_binary_available() -> bool {
#[cfg(test)]
if let Some(v) = GIT_BINARY_AVAILABLE_OVERRIDE.with(|c| c.get()) {
return v;
}
*GIT_BINARY_AVAILABLE.get_or_init(|| {
std::process::Command::new("git")
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false) })
}
#[cfg_attr(not(feature = "git"), allow(dead_code))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileStatus {
Modified,
Added,
Untracked,
Deleted,
Renamed,
TypeChange,
Conflicted,
}
impl FileStatus {
pub fn marker(self) -> char {
match self {
FileStatus::Modified => 'M',
FileStatus::Added => 'A',
FileStatus::Untracked => 'U',
FileStatus::Deleted => 'D',
FileStatus::Renamed => 'R',
FileStatus::TypeChange => 'T',
FileStatus::Conflicted => '!',
}
}
pub fn color(self) -> Color {
match self {
FileStatus::Modified => Color::Yellow,
FileStatus::Added => Color::Green, FileStatus::Untracked => Color::LightGreen, FileStatus::Deleted => Color::Red,
FileStatus::Renamed => Color::Cyan,
FileStatus::TypeChange => Color::Yellow,
FileStatus::Conflicted => Color::LightRed,
}
}
#[cfg_attr(not(feature = "git"), allow(dead_code))]
fn rank(self) -> u8 {
match self {
FileStatus::Conflicted => 6,
FileStatus::Deleted => 5,
FileStatus::Modified => 4,
FileStatus::Renamed => 3,
FileStatus::Added => 2,
FileStatus::TypeChange => 1,
FileStatus::Untracked => 0,
}
}
}
#[cfg(all(feature = "git", target_os = "macos"))]
pub(crate) fn normalize_status_path(path: PathBuf) -> PathBuf {
match path.to_str() {
Some(s) if s.is_ascii() => path,
_ => path.canonicalize().unwrap_or(path),
}
}
#[cfg(all(feature = "git", not(target_os = "macos")))]
pub(crate) fn normalize_status_path(path: PathBuf) -> PathBuf {
path
}
#[cfg(feature = "git")]
pub fn statuses(root: &Path) -> HashMap<PathBuf, FileStatus> {
if !external_git_enabled() {
return HashMap::new();
}
#[cfg(test)]
STATUS_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
use std::os::unix::ffi::OsStrExt;
let mut map = HashMap::new();
let Some(workdir) = workdir(root) else {
return map; };
let out = std::process::Command::new("git")
.current_dir(&workdir)
.args([
"--no-optional-locks", "-c",
"status.renames=true",
"status",
"--porcelain=v1",
"-z",
"-uall",
"--ignored=no",
])
.output();
let Ok(out) = out else {
return map;
};
if !out.status.success() {
return map;
}
let mut fields = out.stdout.split(|&b| b == 0);
while let Some(rec) = fields.next() {
if rec.len() < 4 {
continue;
}
let (x, y) = (rec[0], rec[1]);
let is_rename = x == b'R' || x == b'C' || y == b'R' || y == b'C';
if is_rename {
let _ = fields.next();
}
let rel = PathBuf::from(std::ffi::OsStr::from_bytes(&rec[3..]));
let abs = workdir.join(rel);
let abs = normalize_status_path(abs);
rollup(&mut map, &workdir, &abs, classify_porcelain(x, y));
}
map
}
#[cfg(feature = "git")]
fn classify_porcelain(x: u8, y: u8) -> FileStatus {
if x == b'U' || y == b'U' || (x == b'A' && y == b'A') || (x == b'D' && y == b'D') {
FileStatus::Conflicted } else if x == b'?' {
FileStatus::Untracked } else if x == b'D' || y == b'D' {
FileStatus::Deleted
} else if x == b'A' {
FileStatus::Added
} else if x == b'R' || y == b'R' || x == b'C' || y == b'C' {
FileStatus::Renamed
} else if x == b'M' || y == b'M' {
FileStatus::Modified
} else if x == b'T' || y == b'T' {
FileStatus::TypeChange
} else {
FileStatus::Modified
}
}
#[cfg(not(feature = "git"))]
pub fn statuses(_root: &Path) -> HashMap<PathBuf, FileStatus> {
HashMap::new()
}
#[cfg(feature = "git")]
pub fn ignored(root: &Path) -> HashSet<PathBuf> {
if !external_git_enabled() {
return HashSet::new();
}
use std::os::unix::ffi::OsStrExt;
let mut set = HashSet::new();
let Some(workdir) = workdir(root) else {
return set; };
let out = std::process::Command::new("git")
.current_dir(&workdir)
.args([
"--no-optional-locks", "status",
"--porcelain=v1",
"-z",
"--ignored=traditional",
"-unormal",
])
.output();
let Ok(out) = out else {
return set;
};
if !out.status.success() {
return set;
}
for rec in out.stdout.split(|&b| b == 0) {
if rec.len() < 4 || rec[0] != b'!' || rec[1] != b'!' {
continue;
}
let mut raw = &rec[3..];
while raw.last() == Some(&b'/') {
raw = &raw[..raw.len() - 1];
}
set.insert(workdir.join(Path::new(std::ffi::OsStr::from_bytes(raw))));
}
set
}
#[cfg(not(feature = "git"))]
pub fn ignored(_root: &Path) -> HashSet<PathBuf> {
HashSet::new()
}
#[cfg(feature = "git")]
#[derive(Clone, Default)]
struct RepoDirs {
workdir: Option<PathBuf>,
git_dir: Option<PathBuf>,
common_dir: Option<PathBuf>,
}
#[cfg(feature = "git")]
static REPO_DIRS_CACHE: std::sync::OnceLock<std::sync::Mutex<HashMap<PathBuf, RepoDirs>>> =
std::sync::OnceLock::new();
#[cfg(all(test, feature = "git"))]
thread_local! {
static DISCOVERY_CLI_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(all(test, feature = "git"))]
pub fn count_discovery_cli_calls<T>(f: impl FnOnce() -> T) -> (T, usize) {
let before = DISCOVERY_CLI_CALLS.with(|c| c.get());
let out = f();
(
out,
DISCOVERY_CLI_CALLS.with(|c| c.get()).saturating_sub(before),
)
}
#[cfg(feature = "git")]
fn git_marker_dir(start: &Path) -> Option<PathBuf> {
let mut cur = Some(start);
while let Some(dir) = cur {
if std::fs::symlink_metadata(dir.join(".git")).is_ok() {
return Some(dir.to_path_buf());
}
cur = dir.parent();
}
None
}
#[cfg(feature = "git")]
fn repo_dirs_via_cli(marker_dir: &Path) -> RepoDirs {
let cache = REPO_DIRS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
if let Ok(map) = cache.lock() {
if let Some(hit) = map.get(marker_dir) {
return hit.clone();
}
}
#[cfg(test)]
DISCOVERY_CLI_CALLS.with(|c| c.set(c.get() + 1));
let dirs = run_rev_parse_dirs(marker_dir);
if let Ok(mut map) = cache.lock() {
map.insert(marker_dir.to_path_buf(), dirs.clone());
}
dirs
}
#[cfg(feature = "git")]
fn run_rev_parse_dirs(dir: &Path) -> RepoDirs {
use std::os::unix::ffi::OsStrExt;
let out = std::process::Command::new("git")
.current_dir(dir)
.args([
"--no-optional-locks", "rev-parse",
"--path-format=absolute",
"--show-toplevel",
"--git-dir",
"--git-common-dir",
])
.output();
let Ok(out) = out else {
return RepoDirs::default();
};
if !out.status.success() {
return RepoDirs::default();
}
let mut lines = out
.stdout
.split(|&b| b == b'\n')
.filter(|l| !l.is_empty())
.map(|l| {
let p = PathBuf::from(std::ffi::OsStr::from_bytes(l));
p.canonicalize().unwrap_or(p)
});
let workdir = lines.next();
let git_dir = lines.next();
let common_dir = lines.next();
if git_dir.is_none() || common_dir.is_none() {
return RepoDirs::default(); }
RepoDirs {
workdir,
git_dir,
common_dir,
}
}
#[cfg(feature = "git")]
fn resolve_repo_dirs(root: &Path) -> RepoDirs {
if !external_git_enabled() {
return RepoDirs::default();
}
if let Ok(repo) = git2::Repository::discover(root) {
let canon = |p: &Path| {
let p = p.to_path_buf();
p.canonicalize().unwrap_or(p)
};
return RepoDirs {
workdir: repo.workdir().map(canon), git_dir: Some(canon(repo.path())),
common_dir: Some(canon(repo.commondir())),
};
}
let Some(marker_dir) = git_marker_dir(root) else {
return RepoDirs::default();
};
repo_dirs_via_cli(&marker_dir)
}
#[cfg(feature = "git")]
fn open_repo(root: &Path) -> Option<git2::Repository> {
if !external_git_enabled() {
return None;
}
git2::Repository::discover(root).ok()
}
#[cfg(all(test, feature = "git"))]
thread_local! {
static READ_CLI_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(all(test, feature = "git"))]
pub fn count_read_cli_calls<T>(f: impl FnOnce() -> T) -> (T, usize) {
let before = READ_CLI_CALLS.with(|c| c.get());
let out = f();
(out, READ_CLI_CALLS.with(|c| c.get()).saturating_sub(before))
}
#[cfg(feature = "git")]
fn git_read<I, S>(dir: &Path, args: I) -> Option<Vec<u8>>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
#[cfg(test)]
READ_CLI_CALLS.with(|c| c.set(c.get() + 1));
let out = std::process::Command::new("git")
.current_dir(dir)
.arg("--no-optional-locks")
.args(args)
.stdin(std::process::Stdio::null())
.output()
.ok()?;
out.status.success().then_some(out.stdout)
}
#[cfg(feature = "git")]
fn git_read_token<I, S>(dir: &Path, args: I) -> Option<String>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let out = git_read(dir, args)?;
let s = String::from_utf8_lossy(&out).trim().to_string();
(!s.is_empty()).then_some(s)
}
#[cfg(feature = "git")]
fn cli_dir(root: &Path) -> Option<PathBuf> {
resolve_repo_dirs(root).workdir
}
#[cfg(feature = "git")]
fn looks_binary(bytes: &[u8]) -> bool {
bytes.iter().take(8000).any(|&b| b == 0)
}
#[cfg(feature = "git")]
fn cli_blob(dir: &Path, rev: &str, rel: &Path) -> Option<Vec<u8>> {
cat_file_batch(dir, std::slice::from_ref(&blob_spec(rev, rel)))
.into_iter()
.next()
.flatten()
}
#[cfg(feature = "git")]
fn blob_spec(rev: &str, rel: &Path) -> std::ffi::OsString {
let mut spec = std::ffi::OsString::from(rev);
spec.push(":");
spec.push(rel.as_os_str());
spec
}
#[cfg(feature = "git")]
fn cat_file_batch(dir: &Path, specs: &[std::ffi::OsString]) -> Vec<Option<Vec<u8>>> {
use std::io::{BufReader, Write};
use std::os::unix::ffi::OsStrExt;
let mut out: Vec<Option<Vec<u8>>> = specs.iter().map(|_| None).collect();
if specs.is_empty() {
return out;
}
let (batched, lone): (Vec<usize>, Vec<usize>) =
(0..specs.len()).partition(|&i| !specs[i].as_bytes().contains(&b'\n'));
for i in lone {
out[i] = git_read(
dir,
[
std::ffi::OsStr::new("cat-file"),
std::ffi::OsStr::new("blob"),
specs[i].as_os_str(),
],
);
}
if batched.is_empty() {
return out;
}
#[cfg(test)]
READ_CLI_CALLS.with(|c| c.set(c.get() + 1));
let Ok(mut child) = std::process::Command::new("git")
.current_dir(dir)
.arg("--no-optional-locks")
.args(["cat-file", "--batch"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
else {
return out;
};
let (Some(mut stdin), Some(stdout)) = (child.stdin.take(), child.stdout.take()) else {
let _ = child.kill();
let _ = child.wait();
return out;
};
let mut payload = Vec::new();
for &i in &batched {
payload.extend_from_slice(specs[i].as_bytes());
payload.push(b'\n');
}
let writer = std::thread::spawn(move || {
let _ = stdin.write_all(&payload);
let _ = stdin.flush();
});
let mut reader = BufReader::new(stdout);
for &i in &batched {
match read_batch_frame(&mut reader) {
Some(bytes) => out[i] = bytes,
None => break, }
}
let _ = writer.join();
let _ = child.wait();
out
}
#[cfg(feature = "git")]
fn read_batch_frame<R: std::io::BufRead>(r: &mut R) -> Option<Option<Vec<u8>>> {
use std::io::Read;
let mut header = Vec::new();
if r.read_until(b'\n', &mut header).ok()? == 0 {
return None; }
if header.last() == Some(&b'\n') {
header.pop();
}
let text = String::from_utf8_lossy(&header);
let mut tail = text.rsplitn(3, ' ');
let size = tail.next().and_then(|s| s.parse::<usize>().ok());
let kind = tail.next();
let (Some(size), Some(kind)) = (size, kind) else {
return Some(None); };
if !matches!(kind, "blob" | "tree" | "commit" | "tag") {
return Some(None);
}
let mut buf = Vec::new();
r.by_ref().take(size as u64).read_to_end(&mut buf).ok()?;
if buf.len() != size {
return None; }
let mut nl = [0u8; 1];
r.read_exact(&mut nl).ok()?;
Some((kind == "blob").then_some(buf))
}
#[cfg(feature = "git")]
fn cli_paths(out: &[u8]) -> Vec<PathBuf> {
use std::os::unix::ffi::OsStrExt;
out.split(|&b| b == 0)
.filter(|f| !f.is_empty())
.map(|f| PathBuf::from(std::ffi::OsStr::from_bytes(f)))
.collect()
}
#[cfg(feature = "git")]
pub(crate) fn push_file_diff(
out: &mut Vec<DiffLine>,
rel: &Path,
old: Option<&[u8]>,
new: Option<&[u8]>,
with_headers: bool,
) {
if old.is_none() && new.is_none() {
return;
}
let (old_b, new_b) = (old.unwrap_or_default(), new.unwrap_or_default());
if with_headers {
out.push(DiffLine {
kind: DiffLineKind::Context,
old_no: None,
new_no: None,
text: rel.to_string_lossy().into_owned(),
});
}
if looks_binary(old_b) || looks_binary(new_b) {
return;
}
out.extend(diff_contents(
&String::from_utf8_lossy(old_b),
&String::from_utf8_lossy(new_b),
));
}
#[cfg(feature = "git")]
fn cli_paths_vs_worktree(dir: &Path, old: Option<&str>) -> Vec<PathBuf> {
let tracked = match old {
Some(rev) => git_read(
dir,
["diff", "--name-only", "--no-renames", "-z", rev, "--"],
),
None => git_read(dir, ["ls-files", "-z", "--cached"]),
};
let untracked = git_read(dir, ["ls-files", "-z", "--others", "--exclude-standard"]);
let mut paths: Vec<PathBuf> = tracked
.as_deref()
.map(cli_paths)
.unwrap_or_default()
.into_iter()
.chain(untracked.as_deref().map(cli_paths).unwrap_or_default())
.collect();
paths.sort();
paths.dedup();
paths
}
#[cfg(feature = "git")]
fn cli_diff_vs_worktree(dir: &Path, old: Option<&str>) -> Vec<DiffLine> {
let paths = cli_paths_vs_worktree(dir, old);
let before: Vec<Option<Vec<u8>>> = match old {
Some(rev) => {
let specs: Vec<_> = paths.iter().map(|rel| blob_spec(rev, rel)).collect();
cat_file_batch(dir, &specs)
}
None => paths.iter().map(|_| None).collect(),
};
let mut out = Vec::new();
for (rel, before) in paths.iter().zip(before) {
let after = std::fs::read(dir.join(rel)).ok();
push_file_diff(&mut out, rel, before.as_deref(), after.as_deref(), true);
}
out
}
#[cfg(feature = "git")]
pub fn workdir(root: &Path) -> Option<PathBuf> {
resolve_repo_dirs(root).workdir
}
#[cfg(not(feature = "git"))]
pub fn workdir(_root: &Path) -> Option<PathBuf> {
None
}
#[cfg(feature = "git")]
pub fn git_dir(root: &Path) -> Option<PathBuf> {
resolve_repo_dirs(root).git_dir
}
#[cfg(not(feature = "git"))]
pub fn git_dir(_root: &Path) -> Option<PathBuf> {
None
}
#[cfg(feature = "git")]
pub fn branch(root: &Path) -> Option<String> {
if !external_git_enabled() {
return None;
}
if let Some(repo) = open_repo(root) {
if let Ok(head) = repo.head() {
return head.shorthand().ok().map(|s| s.to_string());
}
return repo
.find_reference("HEAD")
.ok()
.and_then(|r| r.symbolic_target().ok().flatten().map(|t| t.to_string()))
.map(|t| t.trim_start_matches("refs/heads/").to_string());
}
branch_via_cli(&git_marker_dir(root)?)
}
#[cfg(feature = "git")]
fn branch_via_cli(dir: &Path) -> Option<String> {
let run = |args: &[&str]| -> Option<String> {
let out = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!s.is_empty()).then_some(s)
};
match run(&["--no-optional-locks", "rev-parse", "--abbrev-ref", "HEAD"]) {
Some(name) if name != "HEAD" => Some(name),
Some(_) => run(&["--no-optional-locks", "rev-parse", "--short", "HEAD"]),
None => run(&["--no-optional-locks", "branch", "--show-current"]),
}
}
#[cfg(not(feature = "git"))]
pub fn branch(_root: &Path) -> Option<String> {
None
}
#[cfg(feature = "git")]
pub fn worktree_origin(root: &Path) -> Option<String> {
let dirs = resolve_repo_dirs(root);
let (git_dir, commondir) = (dirs.git_dir?, dirs.common_dir?);
if git_dir == commondir {
return None; }
let name = commondir.file_name()?.to_str()?;
if name == ".git" {
commondir
.parent()?
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
} else {
Some(name.strip_suffix(".git").unwrap_or(name).to_string())
}
}
#[cfg(not(feature = "git"))]
pub fn worktree_origin(_root: &Path) -> Option<String> {
None
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffLineKind {
Context,
Added,
Removed,
}
#[derive(Debug, Clone)]
pub struct DiffLine {
pub kind: DiffLineKind,
pub old_no: Option<u32>,
pub new_no: Option<u32>,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct ChangeEntry {
pub path: PathBuf,
pub status: FileStatus,
pub staged: bool,
}
#[derive(Debug, Clone)]
pub struct CommitInfo {
pub id: String,
pub short: String,
pub summary: String,
pub author: String,
pub time_epoch: i64,
}
#[derive(Debug, Clone)]
pub struct BranchInfo {
pub name: String,
pub is_current: bool,
}
#[cfg(feature = "git")]
pub fn branches(root: &Path) -> Vec<BranchInfo> {
if !external_git_enabled() {
return Vec::new();
}
let Some(repo) = open_repo(root) else {
return branches_via_cli(root);
};
let mut out = Vec::new();
if let Ok(iter) = repo.branches(Some(git2::BranchType::Local)) {
for (branch, _) in iter.flatten() {
let is_current = branch.is_head();
if let Ok(Some(name)) = branch.name() {
out.push(BranchInfo {
name: name.to_string(),
is_current,
});
}
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
#[cfg(feature = "git")]
fn branches_via_cli(root: &Path) -> Vec<BranchInfo> {
let Some(dir) = cli_dir(root) else {
return Vec::new();
};
let Some(out) = git_read(
&dir,
["for-each-ref", "--format=%(refname:short)%00%(HEAD)", REFS],
) else {
return Vec::new();
};
let text = String::from_utf8_lossy(&out);
let mut list: Vec<BranchInfo> = text
.lines()
.filter_map(|line| {
let (name, head) = line.split_once('\0')?;
(!name.is_empty()).then(|| BranchInfo {
name: name.to_string(),
is_current: head.trim() == "*",
})
})
.collect();
list.sort_by(|a, b| a.name.cmp(&b.name));
list
}
#[cfg(feature = "git")]
const REFS: &str = "refs/heads/";
#[cfg(not(feature = "git"))]
pub fn branches(_root: &Path) -> Vec<BranchInfo> {
Vec::new()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeInfo {
pub path: PathBuf,
pub branch: Option<String>,
pub head: Option<String>,
pub is_current: bool,
pub is_main: bool,
pub is_bare: bool,
pub locked: Option<String>,
pub prunable: bool,
}
#[cfg(feature = "git")]
pub fn worktrees(root: &Path) -> Vec<WorktreeInfo> {
if !external_git_enabled() {
return Vec::new();
}
let Some(cwd) = workdir(root) else {
return Vec::new(); };
let out = std::process::Command::new("git")
.current_dir(&cwd)
.args([
"--no-optional-locks", "worktree",
"list",
"--porcelain",
"-z",
])
.output();
let Ok(out) = out else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}
let current = workdir(root);
let mut list: Vec<WorktreeInfo> = Vec::new();
let mut rec: Vec<&[u8]> = Vec::new();
for field in out.stdout.split(|&b| b == 0) {
if field.is_empty() {
if !rec.is_empty() {
let is_main = list.is_empty();
list.push(worktree_from_record(&rec, is_main, current.as_deref()));
rec.clear();
}
continue;
}
rec.push(field);
}
if !rec.is_empty() {
let is_main = list.is_empty();
list.push(worktree_from_record(&rec, is_main, current.as_deref()));
}
list
}
#[cfg(feature = "git")]
fn worktree_from_record(fields: &[&[u8]], is_main: bool, current: Option<&Path>) -> WorktreeInfo {
use std::os::unix::ffi::OsStrExt;
let mut path = PathBuf::new();
let mut head: Option<String> = None;
let mut branch: Option<String> = None;
let mut is_bare = false;
let mut locked: Option<String> = None;
let mut prunable = false;
for &f in fields {
if let Some(rest) = f.strip_prefix(b"worktree ") {
path = PathBuf::from(std::ffi::OsStr::from_bytes(rest));
continue;
}
let line = String::from_utf8_lossy(f);
if let Some(rest) = line.strip_prefix("HEAD ") {
if !rest.bytes().all(|b| b == b'0') {
head = Some(rest.chars().take(7).collect());
}
} else if let Some(rest) = line.strip_prefix("branch ") {
branch = Some(
rest.strip_prefix("refs/heads/")
.map(str::to_string)
.unwrap_or_else(|| rest.to_string()),
);
} else if line == "bare" {
is_bare = true;
} else if line == "locked" {
locked = Some(String::new());
} else if let Some(rest) = line.strip_prefix("locked ") {
locked = Some(rest.to_string());
} else if line == "prunable" || line.starts_with("prunable ") {
prunable = true;
}
}
let path = path.canonicalize().unwrap_or(path);
let is_current = current.is_some_and(|c| c == path);
WorktreeInfo {
path,
branch,
head,
is_current,
is_main,
is_bare,
locked,
prunable,
}
}
#[cfg(not(feature = "git"))]
pub fn worktrees(_root: &Path) -> Vec<WorktreeInfo> {
Vec::new()
}
#[cfg(feature = "git")]
pub fn branches_by_recency(root: &Path) -> Vec<(String, bool, i64)> {
if !external_git_enabled() {
return Vec::new();
}
let Some(repo) = open_repo(root) else {
return branches_by_recency_via_cli(root);
};
let mut out: Vec<(String, bool, i64)> = Vec::new();
if let Ok(iter) = repo.branches(Some(git2::BranchType::Local)) {
for (branch, _) in iter.flatten() {
let is_current = branch.is_head();
let Ok(Some(name)) = branch.name() else {
continue;
};
let t = branch
.get()
.peel_to_commit()
.map(|c| c.time().seconds())
.unwrap_or(0);
out.push((name.to_string(), is_current, t));
}
}
out.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
out
}
#[cfg(feature = "git")]
fn branches_by_recency_via_cli(root: &Path) -> Vec<(String, bool, i64)> {
let Some(dir) = cli_dir(root) else {
return Vec::new();
};
let Some(out) = git_read(
&dir,
[
"for-each-ref",
"--format=%(refname:short)%00%(HEAD)%00%(committerdate:unix)",
REFS,
],
) else {
return Vec::new();
};
let text = String::from_utf8_lossy(&out);
let mut list: Vec<(String, bool, i64)> = text
.lines()
.filter_map(|line| {
let mut it = line.split('\0');
let name = it.next()?;
let head = it.next()?;
let t = it.next().unwrap_or("").trim().parse::<i64>().unwrap_or(0);
(!name.is_empty()).then(|| (name.to_string(), head.trim() == "*", t))
})
.collect();
list.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
list
}
#[cfg(not(feature = "git"))]
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub fn branches_by_recency(_root: &Path) -> Vec<(String, bool, i64)> {
Vec::new()
}
#[cfg(feature = "git")]
pub fn branch_tip(root: &Path, name: &str) -> Option<String> {
if !external_git_enabled() {
return None;
}
let Some(repo) = open_repo(root) else {
return branch_tip_via_cli(root, name);
};
let branch = repo.find_branch(name, git2::BranchType::Local).ok()?;
let oid = branch.get().peel_to_commit().ok()?.id();
Some(oid.to_string())
}
#[cfg(feature = "git")]
fn branch_tip_via_cli(root: &Path, name: &str) -> Option<String> {
let dir = cli_dir(root)?;
git_read_token(
&dir,
["rev-parse", "--verify", &format!("{REFS}{name}^{{commit}}")],
)
}
#[cfg(not(feature = "git"))]
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub fn branch_tip(_root: &Path, _name: &str) -> Option<String> {
None
}
#[derive(Debug, Clone)]
pub struct LegendEntry {
pub name: String,
pub color: ratatui::style::Color,
pub is_head: bool,
pub is_base: bool,
}
#[cfg(feature = "git")]
pub fn legend_from_rows(
rows: &[GraphRow],
root: &Path,
base_label: Option<&str>,
) -> Vec<LegendEntry> {
use ratatui::style::Color;
use std::collections::{HashMap, HashSet};
let locals = branches(root);
let local_names: HashSet<&str> = locals.iter().map(|b| b.name.as_str()).collect();
let head_branch = locals.iter().find(|b| b.is_current).map(|b| b.name.clone());
let mut order: Vec<String> = Vec::new();
let mut color_of: HashMap<String, Color> = HashMap::new();
for r in rows {
if r.commit.is_none() || r.refs.is_empty() {
continue;
}
let node_color = r
.node_col
.and_then(|i| r.graph.get(i))
.and_then(|(_, st)| st.fg)
.unwrap_or(Color::Reset);
for tok in r.refs.split(',') {
let tok = tok.trim();
let name = tok.strip_prefix("HEAD -> ").unwrap_or(tok);
if name == "HEAD" || name.starts_with("tag:") || !local_names.contains(name) {
continue;
}
if color_of.contains_key(name) {
continue;
}
color_of.insert(name.to_string(), node_color);
order.push(name.to_string());
}
}
let mut out: Vec<LegendEntry> = order
.into_iter()
.map(|name| {
let is_head = head_branch.as_deref() == Some(name.as_str());
let is_base = base_label == Some(name.as_str());
let color = color_of.get(&name).copied().unwrap_or(Color::Reset);
LegendEntry {
name,
color,
is_head,
is_base,
}
})
.collect();
out.sort_by_key(|e| (!e.is_head, !e.is_base));
out
}
#[cfg(not(feature = "git"))]
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub fn legend_from_rows(
_rows: &[GraphRow],
_root: &Path,
_base_label: Option<&str>,
) -> Vec<LegendEntry> {
Vec::new()
}
#[cfg(all(feature = "git", target_os = "macos"))]
fn precomposed_pathspec(workdir: &Path, rel: &str) -> String {
if rel.is_ascii() {
return rel.to_string();
}
let out = std::process::Command::new("git")
.current_dir(workdir)
.args([
"--no-optional-locks", "status",
"--porcelain=v1",
"-z",
"-uall",
"--",
rel,
])
.output();
let Ok(out) = out else {
return rel.to_string();
};
if !out.status.success() {
return rel.to_string();
}
let Some(rec) = out.stdout.split(|&b| b == 0).find(|r| r.len() >= 4) else {
return rel.to_string();
};
String::from_utf8_lossy(&rec[3..]).to_string()
}
#[cfg(all(feature = "git", not(target_os = "macos")))]
fn precomposed_pathspec(_workdir: &Path, rel: &str) -> String {
rel.to_string()
}
#[cfg(feature = "git")]
pub fn file_diff(root: &Path, file: &Path) -> Vec<DiffLine> {
if !external_git_enabled() {
return Vec::new();
}
let Some(repo) = open_repo(root) else {
return file_diff_via_cli(root, file);
};
let Some(workdir) = repo.workdir() else {
return Vec::new();
};
let workdir = workdir
.canonicalize()
.unwrap_or_else(|_| workdir.to_path_buf());
let file_abs = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
let rel = file_abs
.strip_prefix(&workdir)
.unwrap_or(&file_abs)
.to_path_buf();
let rel_str = rel.to_string_lossy().to_string();
let rel_str = precomposed_pathspec(&workdir, &rel_str);
let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
let mut opts = git2::DiffOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.show_untracked_content(true)
.pathspec(&rel_str);
let diff = match repo.diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut opts)) {
Ok(d) => d,
Err(_) => return Vec::new(),
};
collect_diff_lines(&diff, false)
}
#[cfg(feature = "git")]
fn file_diff_via_cli(root: &Path, file: &Path) -> Vec<DiffLine> {
let Some(dir) = cli_dir(root) else {
return Vec::new();
};
let file_abs = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
let Ok(rel) = file_abs.strip_prefix(&dir) else {
return Vec::new(); };
let before = cli_blob(&dir, "HEAD", rel);
let after = std::fs::read(&file_abs).ok();
let mut out = Vec::new();
push_file_diff(&mut out, rel, before.as_deref(), after.as_deref(), false);
out
}
#[cfg(not(feature = "git"))]
pub fn file_diff(_root: &Path, _file: &Path) -> Vec<DiffLine> {
Vec::new()
}
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub fn diff_contents(old: &str, new: &str) -> Vec<DiffLine> {
let diff = similar::TextDiff::from_lines(old, new);
let mut out = Vec::new();
for group in diff.grouped_ops(3) {
for op in &group {
for change in diff.iter_changes(op) {
let raw = change.value();
let text = raw
.strip_suffix('\n')
.unwrap_or(raw)
.strip_suffix('\r')
.unwrap_or_else(|| raw.strip_suffix('\n').unwrap_or(raw))
.to_string();
let old_no = change.old_index().map(|i| i as u32 + 1);
let new_no = change.new_index().map(|i| i as u32 + 1);
let kind = match change.tag() {
similar::ChangeTag::Equal => DiffLineKind::Context,
similar::ChangeTag::Delete => DiffLineKind::Removed,
similar::ChangeTag::Insert => DiffLineKind::Added,
};
out.push(DiffLine {
kind,
old_no,
new_no,
text,
});
}
}
}
out
}
#[cfg(feature = "git")]
pub fn head_commit_id(root: &Path) -> Option<String> {
if !external_git_enabled() {
return None;
}
let Some(repo) = open_repo(root) else {
let dir = cli_dir(root)?;
return git_read_token(&dir, ["rev-parse", "--verify", "HEAD^{commit}"]);
};
let commit = repo.head().ok()?.peel_to_commit().ok()?;
Some(commit.id().to_string())
}
#[cfg(feature = "git")]
pub fn blob_at(root: &Path, sha: &str, file: &Path) -> Option<Vec<u8>> {
if !external_git_enabled() {
return None;
}
let Some(repo) = open_repo(root) else {
let dir = cli_dir(root)?;
let file_abs = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
let rel = file_abs.strip_prefix(&dir).ok()?;
return cli_blob(&dir, sha, rel);
};
let workdir = repo.workdir()?;
let workdir = workdir
.canonicalize()
.unwrap_or_else(|_| workdir.to_path_buf());
let file_abs = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
let rel = file_abs.strip_prefix(&workdir).ok()?;
let oid = git2::Oid::from_str(sha).ok()?;
let tree = repo.find_commit(oid).ok()?.tree().ok()?;
let entry = tree.get_path(rel).ok()?;
let obj = entry.to_object(&repo).ok()?;
Some(obj.as_blob()?.content().to_vec())
}
#[cfg(feature = "git")]
fn collect_diff_lines(diff: &git2::Diff, with_headers: bool) -> Vec<DiffLine> {
use std::cell::RefCell;
let lines: RefCell<Vec<DiffLine>> = RefCell::new(Vec::new());
let last_file: RefCell<Option<String>> = RefCell::new(None);
let _ = diff.foreach(
&mut |delta, _| {
if with_headers {
let path = delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let mut lf = last_file.borrow_mut();
if lf.as_deref() != Some(path.as_str()) {
lines.borrow_mut().push(DiffLine {
kind: DiffLineKind::Context,
old_no: None,
new_no: None,
text: path.clone(),
});
*lf = Some(path);
}
}
true
},
None,
None,
Some(&mut |_delta, _hunk, line| {
let kind = match line.origin() {
'+' => DiffLineKind::Added,
'-' => DiffLineKind::Removed,
_ => DiffLineKind::Context,
};
let text = String::from_utf8_lossy(line.content())
.trim_end_matches(['\n', '\r'])
.to_string();
lines.borrow_mut().push(DiffLine {
kind,
old_no: line.old_lineno(),
new_no: line.new_lineno(),
text,
});
true
}),
);
lines.into_inner()
}
#[cfg(feature = "git")]
pub fn changed_files(root: &Path) -> Vec<ChangeEntry> {
if !external_git_enabled() {
return Vec::new();
}
use std::os::unix::ffi::OsStrExt;
let mut out = Vec::new();
let Some(workdir) = workdir(root) else {
return out; };
let cmd_out = std::process::Command::new("git")
.current_dir(&workdir)
.args([
"--no-optional-locks", "-c",
"status.renames=true",
"status",
"--porcelain=v1",
"-z",
"-uall",
"--ignored=no",
])
.output();
let Ok(cmd_out) = cmd_out else {
return out;
};
if !cmd_out.status.success() {
return out;
}
let mut fields = cmd_out.stdout.split(|&b| b == 0);
while let Some(rec) = fields.next() {
if rec.len() < 4 {
continue;
}
let (x, y) = (rec[0], rec[1]);
let is_rename = x == b'R' || x == b'C' || y == b'R' || y == b'C';
if is_rename {
let _ = fields.next();
}
let staged = matches!(x, b'M' | b'A' | b'D' | b'R' | b'C' | b'T');
let rel = PathBuf::from(std::ffi::OsStr::from_bytes(&rec[3..]));
out.push(ChangeEntry {
path: workdir.join(rel),
status: classify_porcelain(x, y),
staged,
});
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
#[cfg(not(feature = "git"))]
pub fn changed_files(_root: &Path) -> Vec<ChangeEntry> {
Vec::new()
}
#[cfg(feature = "git")]
pub fn log(root: &Path, max: usize) -> Vec<CommitInfo> {
let mut out = Vec::new();
if !external_git_enabled() {
return out;
}
let Some(repo) = open_repo(root) else {
return log_via_cli(root, max);
};
let Ok(mut walk) = repo.revwalk() else {
return out;
};
if walk.push_head().is_err() {
return out; }
let _ = walk.set_sorting(git2::Sort::TIME);
for oid in walk.flatten().take(max) {
let Ok(commit) = repo.find_commit(oid) else {
continue;
};
let id = oid.to_string();
let short = id.chars().take(7).collect();
let summary = commit.summary().ok().flatten().unwrap_or("").to_string();
let author = commit.author().name().unwrap_or("").to_string();
let time_epoch = commit.time().seconds();
out.push(CommitInfo {
id,
short,
summary,
author,
time_epoch,
});
}
out
}
#[cfg(feature = "git")]
fn log_via_cli(root: &Path, max: usize) -> Vec<CommitInfo> {
let Some(dir) = cli_dir(root) else {
return Vec::new();
};
let Some(out) = git_read(
&dir,
[
"log",
&format!("--max-count={max}"),
"--format=%x1f%H%x1f%h%x1f%an%x1f%ct%x1f%s",
],
) else {
return Vec::new();
};
String::from_utf8_lossy(&out)
.lines()
.filter_map(|line| {
let mut it = line.split('\u{1f}');
let _lead = it.next(); let id = it.next()?.to_string();
if id.is_empty() {
return None;
}
let short = it.next().unwrap_or("").to_string();
let author = it.next().unwrap_or("").to_string();
let time_epoch = it.next().unwrap_or("").parse().unwrap_or(0);
let summary = it.next().unwrap_or("").to_string();
Some(CommitInfo {
id,
short,
summary,
author,
time_epoch,
})
})
.collect()
}
#[cfg(not(feature = "git"))]
pub fn log(_root: &Path, _max: usize) -> Vec<CommitInfo> {
Vec::new()
}
#[cfg(feature = "git")]
pub fn commit_diff(root: &Path, id: &str) -> Vec<DiffLine> {
if !external_git_enabled() {
return Vec::new();
}
let Some(repo) = open_repo(root) else {
return commit_diff_via_cli(root, id);
};
let Ok(oid) = git2::Oid::from_str(id) else {
return Vec::new();
};
let Ok(commit) = repo.find_commit(oid) else {
return Vec::new();
};
let Ok(new_tree) = commit.tree() else {
return Vec::new();
};
let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
let diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&new_tree), None) {
Ok(d) => d,
Err(_) => return Vec::new(),
};
collect_diff_lines(&diff, true)
}
#[cfg(feature = "git")]
fn commit_diff_via_cli(root: &Path, id: &str) -> Vec<DiffLine> {
let Some(dir) = cli_dir(root) else {
return Vec::new();
};
let Some(listing) = git_read(
&dir,
[
"diff-tree",
"-r",
"--root",
"--no-commit-id",
"--name-only",
"--no-renames",
"-z",
id,
"--",
],
) else {
return Vec::new(); };
let parent = format!("{id}^");
let paths = cli_paths(&listing);
let specs: Vec<_> = paths
.iter()
.map(|rel| blob_spec(&parent, rel))
.chain(paths.iter().map(|rel| blob_spec(id, rel)))
.collect();
let blobs = cat_file_batch(&dir, &specs);
let (before, after) = blobs.split_at(paths.len());
let mut out = Vec::new();
for ((rel, before), after) in paths.iter().zip(before).zip(after) {
push_file_diff(&mut out, rel, before.as_deref(), after.as_deref(), true);
}
out
}
#[derive(Debug, Clone)]
pub struct CommitMeta {
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub id: String,
pub short: String,
pub author: String,
pub date: String,
pub message: String,
}
#[cfg(feature = "git")]
pub fn commit_meta(root: &Path, id: &str) -> Option<CommitMeta> {
if !external_git_enabled() {
return None;
}
let Some(repo) = open_repo(root) else {
return commit_meta_via_cli(root, id);
};
let oid = git2::Oid::from_str(id).ok()?;
let commit = repo.find_commit(oid).ok()?;
let message = commit.message().unwrap_or("").trim_end().to_string();
let author = commit.author().name().unwrap_or("").to_string();
let secs = commit.time().seconds().max(0) as u64;
let date = crate::fileops::format_epoch_short(secs);
Some(CommitMeta {
id: id.to_string(),
short: short_id(id),
author,
date,
message,
})
}
#[cfg(feature = "git")]
fn short_id(id: &str) -> String {
id[..id.len().min(7)].to_string()
}
#[cfg(feature = "git")]
fn commit_meta_via_cli(root: &Path, id: &str) -> Option<CommitMeta> {
let dir = cli_dir(root)?;
let out = git_read(&dir, ["show", "-s", "--format=%an%x00%ct%x00%B", id, "--"])?;
let text = String::from_utf8_lossy(&out);
let mut it = text.splitn(3, '\0');
let author = it.next()?.to_string();
let secs: i64 = it.next()?.trim().parse().unwrap_or(0);
let message = it.next().unwrap_or("").trim_end().to_string();
Some(CommitMeta {
id: id.to_string(),
short: short_id(id),
author,
date: crate::fileops::format_epoch_short(secs.max(0) as u64),
message,
})
}
#[cfg(not(feature = "git"))]
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub fn commit_meta(_root: &Path, _id: &str) -> Option<CommitMeta> {
None
}
#[cfg(not(feature = "git"))]
pub fn commit_diff(_root: &Path, _id: &str) -> Vec<DiffLine> {
Vec::new()
}
#[cfg(feature = "git")]
fn workdir_of(root: &Path) -> PathBuf {
workdir(root).unwrap_or_else(|| root.to_path_buf())
}
#[cfg(feature = "git")]
fn git_error_message(out: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&out.stderr);
let lines: Vec<&str> = stderr
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
if let Some(l) = lines.iter().find(|l| l.starts_with("fatal:")) {
return (*l).to_string();
}
if let Some(l) = lines.iter().find(|l| l.starts_with("error:")) {
return (*l).to_string();
}
if let Some(l) = lines.last() {
return (*l).to_string();
}
format!("git exited with {}", out.status)
}
#[cfg(feature = "git")]
fn run_git(root: &Path, args: &[&str]) -> anyhow::Result<()> {
use anyhow::{anyhow, Context};
if !external_git_enabled() {
return Err(anyhow!("git disabled (external.git = false)"));
}
let cwd = workdir_of(root);
let mut cmd = std::process::Command::new("git");
cmd.current_dir(&cwd).args(args);
#[cfg(test)]
cmd.env("LC_ALL", "C");
let out = cmd
.output()
.with_context(|| format!("failed to launch git {}", args.join(" ")))?;
if out.status.success() {
Ok(())
} else {
Err(anyhow!(git_error_message(&out)))
}
}
#[cfg(feature = "git")]
pub fn stage(root: &Path, file: &Path) -> anyhow::Result<()> {
run_git(root, &["add", "--", &file.to_string_lossy()])
}
#[cfg(feature = "git")]
pub fn unstage(root: &Path, file: &Path) -> anyhow::Result<()> {
run_git(
root,
&["reset", "-q", "HEAD", "--", &file.to_string_lossy()],
)
}
#[cfg(feature = "git")]
pub fn stage_all(root: &Path) -> anyhow::Result<()> {
run_git(root, &["add", "-A"])
}
#[cfg(feature = "git")]
pub fn unstage_all(root: &Path) -> anyhow::Result<()> {
run_git(root, &["reset", "-q", "HEAD"])
}
#[cfg(feature = "git")]
pub fn discard(root: &Path, file: &Path) -> anyhow::Result<()> {
run_git(root, &["checkout", "-q", "--", &file.to_string_lossy()])
}
#[cfg(feature = "git")]
pub fn commit(root: &Path, message: &str) -> anyhow::Result<()> {
run_git(root, &["commit", "-m", message])
}
#[cfg(feature = "git")]
pub fn checkout(root: &Path, name: &str) -> anyhow::Result<()> {
run_git(root, &["switch", name])
}
#[cfg(feature = "git")]
pub fn create_branch(root: &Path, name: &str) -> anyhow::Result<()> {
run_git(root, &["switch", "-c", name])
}
#[cfg(feature = "git")]
pub fn delete_branch(root: &Path, name: &str, force: bool) -> anyhow::Result<()> {
let flag = if force { "-D" } else { "-d" };
run_git(root, &["branch", flag, name])
}
#[cfg(feature = "git")]
pub fn worktree_add(
root: &Path,
path: &Path,
branch: &str,
create_branch: bool,
) -> anyhow::Result<()> {
let path = path.to_string_lossy();
if create_branch {
run_git(root, &["worktree", "add", "-b", branch, &path])
} else {
run_git(root, &["worktree", "add", &path, branch])
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub enum NodeKind {
Normal,
Merge,
WorkingCopy,
Immutable,
Conflict,
}
#[derive(Debug, Clone)]
pub struct GraphRow {
pub graph: Vec<(String, ratatui::style::Style)>,
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub node: Option<NodeKind>,
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub node_col: Option<usize>,
pub commit: Option<String>,
pub short: String,
pub subject: String,
pub author: String,
pub date: String,
pub refs: String,
pub worktree: bool,
}
#[cfg(feature = "git")]
pub fn graph_with_base(
root: &Path,
base: Option<&str>,
lang: crate::i18n::Lang,
refs: Option<&[String]>,
) -> Vec<GraphRow> {
if !external_git_enabled() {
return Vec::new();
}
let commits = dag_commits(root, 400, refs);
let wt = worktree_payload(root, lang);
lay_out_lanes(&commits, base, wt, crate::vcs::VcsKind::Git)
}
#[cfg(feature = "git")]
#[derive(Clone)]
pub(crate) struct DagCommit {
pub(crate) id: String,
pub(crate) parents: Vec<String>,
pub(crate) short: String,
pub(crate) subject: String,
pub(crate) author: String,
pub(crate) date: String,
pub(crate) refs: String,
pub(crate) kind: Option<NodeKind>,
}
#[cfg(feature = "git")]
fn dag_commits(root: &Path, max: usize, refs: Option<&[String]>) -> Vec<DagCommit> {
let cwd = workdir_of(root);
let fmt = "--format=%x1f%H%x1f%P%x1f%h%x1f%s%x1f%an%x1f%ad%x1f%D";
let mut args: Vec<String> = vec![
"log".into(),
"--topo-order".into(),
"--date=short".into(),
"-n".into(),
max.to_string(),
fmt.into(),
];
match refs {
Some(r) if !r.is_empty() => args.extend(r.iter().cloned()),
_ => args.push("--all".into()),
}
let out = std::process::Command::new("git")
.current_dir(&cwd)
.args(&args)
.output();
let Ok(out) = out else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}
let text = String::from_utf8_lossy(&out.stdout);
let mut commits = Vec::new();
for line in text.lines() {
let mut it = line.split('\u{1f}');
let _lead = it.next();
let id = it.next().unwrap_or("").to_string();
if id.is_empty() {
continue;
}
let parents = it
.next()
.unwrap_or("")
.split_whitespace()
.map(|s| s.to_string())
.collect();
commits.push(DagCommit {
id,
parents,
short: it.next().unwrap_or("").to_string(),
subject: it.next().unwrap_or("").to_string(),
author: it.next().unwrap_or("").to_string(),
date: it.next().unwrap_or("").to_string(),
refs: it.next().unwrap_or("").to_string(),
kind: None, });
}
commits
}
#[cfg(feature = "git")]
#[derive(Clone)]
struct Lane {
target: String,
color: ratatui::style::Color,
}
#[cfg(feature = "git")]
pub(crate) fn lay_out_lanes(
commits: &[DagCommit],
base: Option<&str>,
wt: Option<(String, String)>,
vcs: crate::vcs::VcsKind,
) -> Vec<GraphRow> {
use ratatui::style::Color;
const WT_ID: &str = "\u{1}WORKTREE\u{1}";
let work: Vec<DagCommit> = if let Some((subject, date)) = wt.as_ref() {
let head = head_id_of(commits);
let mut v = Vec::with_capacity(commits.len() + 1);
v.push(DagCommit {
id: WT_ID.to_string(),
parents: head.into_iter().collect(),
short: String::new(),
subject: subject.clone(),
author: String::new(),
date: date.clone(),
refs: String::new(),
kind: Some(NodeKind::WorkingCopy),
});
v.extend(commits.iter().cloned());
v
} else {
commits.to_vec()
};
let commits = &work[..];
const PALETTE: [Color; 6] = [
Color::Cyan,
Color::Green,
Color::Magenta,
Color::Blue,
Color::LightRed,
Color::LightYellow,
];
const BASE: Color = Color::White;
let mut lanes: Vec<Option<Lane>> = Vec::new();
let mut next_color = 0usize;
let mut rows: Vec<GraphRow> = Vec::new();
let base_floor = if let Some(tip) = base {
lanes.push(Some(Lane {
target: tip.to_string(),
color: BASE,
}));
1
} else {
0
};
let pick_color = |idx: usize, next: &mut usize| -> Color {
if idx == 0 {
BASE
} else {
let c = PALETTE[*next % PALETTE.len()];
*next += 1;
c
}
};
let free_from = |lanes: &mut Vec<Option<Lane>>, start: usize| -> usize {
if let Some(i) = (start..lanes.len()).find(|&i| lanes[i].is_none()) {
i
} else {
lanes.push(None);
lanes.len() - 1
}
};
let commit_cells = |lanes: &[Option<Lane>], my_lane: usize, node: NodeKind, my_color: Color| {
let n = lanes.len();
let mut glyph = vec![' '; n.saturating_mul(2).saturating_sub(1).max(1)];
let mut color = vec![Color::Reset; glyph.len()];
for (i, l) in lanes.iter().enumerate() {
if i == my_lane {
glyph[i * 2] = crate::ui::icons::node_glyph(node, vcs);
color[i * 2] = my_color;
} else if let Some(l) = l {
glyph[i * 2] = '│';
color[i * 2] = l.color;
}
}
cells_from(glyph, color)
};
for c in commits {
let hits: Vec<usize> = lanes
.iter()
.enumerate()
.filter_map(|(i, l)| match l {
Some(l) if l.target == c.id => Some(i),
_ => None,
})
.collect();
let my_lane = if let Some(&first) = hits.first() {
first
} else {
let idx = free_from(&mut lanes, base_floor);
let col = pick_color(idx, &mut next_color);
lanes[idx] = Some(Lane {
target: c.id.clone(),
color: col,
});
idx
};
let my_color = lanes[my_lane].as_ref().map(|l| l.color).unwrap_or(BASE);
let merged: Vec<(usize, Color)> = hits
.iter()
.skip(1)
.filter_map(|&i| lanes[i].as_ref().map(|l| (i, l.color)))
.collect();
if !merged.is_empty() {
let conn = build_connector(&lanes, my_lane, my_color, &merged, false);
rows.push(connector_row(conn));
for &(i, _) in &merged {
lanes[i] = None;
}
}
let node = c.kind.unwrap_or(if c.id == WT_ID {
NodeKind::WorkingCopy
} else if c.parents.len() >= 2 {
NodeKind::Merge
} else {
NodeKind::Normal
});
rows.push(GraphRow {
graph: commit_cells(&lanes, my_lane, node, my_color),
node: Some(node),
node_col: Some(my_lane * 2),
commit: Some(c.id.clone()),
short: c.short.clone(),
subject: c.subject.clone(),
author: c.author.clone(),
date: c.date.clone(),
refs: c.refs.clone(),
worktree: false,
});
let mut forked: Vec<(usize, Color)> = Vec::new();
if c.parents.is_empty() {
lanes[my_lane] = None; } else {
if let Some(l) = lanes[my_lane].as_mut() {
l.target = c.parents[0].clone();
}
for p in c.parents.iter().skip(1) {
let idx = free_from(&mut lanes, my_lane + 1);
let col = pick_color(idx, &mut next_color);
lanes[idx] = Some(Lane {
target: p.clone(),
color: col,
});
forked.push((idx, col));
}
}
if !forked.is_empty() {
let conn = build_connector(&lanes, my_lane, my_color, &forked, true);
rows.push(connector_row(conn));
}
while matches!(lanes.last(), Some(None)) {
lanes.pop();
}
}
if wt.is_some() {
use ratatui::style::{Modifier, Style};
if let Some(r) = rows
.iter_mut()
.find(|r| r.node == Some(NodeKind::WorkingCopy))
{
r.commit = None;
r.worktree = true;
r.short = String::new();
let node = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
if let Some(cell) = r.node_col.and_then(|i| r.graph.get_mut(i)) {
cell.1 = node;
}
}
}
let maxw = rows.iter().map(|r| r.graph.len()).max().unwrap_or(0);
for r in &mut rows {
while r.graph.len() < maxw {
r.graph
.push((" ".to_string(), ratatui::style::Style::default()));
}
}
rows
}
#[cfg(feature = "git")]
fn head_id_of(commits: &[DagCommit]) -> Option<String> {
commits
.iter()
.find(|c| {
c.refs
.split(',')
.map(|r| r.trim())
.any(|r| r == "HEAD" || r.starts_with("HEAD ->"))
})
.map(|c| c.id.clone())
}
#[cfg(feature = "git")]
fn cells_from(
mut glyph: Vec<char>,
mut color: Vec<ratatui::style::Color>,
) -> Vec<(String, ratatui::style::Style)> {
use ratatui::style::Style;
while glyph.len() > 1 && *glyph.last().unwrap() == ' ' {
glyph.pop();
color.pop();
}
glyph
.into_iter()
.zip(color)
.map(|(g, c)| (g.to_string(), Style::new().fg(c)))
.collect()
}
#[cfg(feature = "git")]
fn connector_row(graph: Vec<(String, ratatui::style::Style)>) -> GraphRow {
GraphRow {
graph,
node: None,
node_col: None,
commit: None,
short: String::new(),
subject: String::new(),
author: String::new(),
date: String::new(),
refs: String::new(),
worktree: false,
}
}
#[cfg(feature = "git")]
const DIR_U: u8 = 1;
#[cfg(feature = "git")]
const DIR_D: u8 = 2;
#[cfg(feature = "git")]
const DIR_L: u8 = 4;
#[cfg(feature = "git")]
const DIR_R: u8 = 8;
#[cfg(feature = "git")]
fn glyph_for(mask: u8) -> char {
match mask {
m if m == DIR_U | DIR_D => '│',
m if m == DIR_L | DIR_R => '─',
m if m == DIR_U | DIR_D | DIR_L | DIR_R => '┼',
m if m == DIR_U | DIR_D | DIR_R => '├',
m if m == DIR_U | DIR_D | DIR_L => '┤',
m if m == DIR_U | DIR_L | DIR_R => '┴',
m if m == DIR_D | DIR_L | DIR_R => '┬',
m if m == DIR_D | DIR_L => '┐',
m if m == DIR_U | DIR_L => '┘',
m if m == DIR_D | DIR_R => '┌',
m if m == DIR_U | DIR_R => '└',
m if m == DIR_U => '│',
m if m == DIR_D => '│',
m if m == DIR_L || m == DIR_R => '─',
_ => ' ',
}
}
#[cfg(feature = "git")]
fn build_connector(
active: &[Option<Lane>],
my_lane: usize,
my_color: ratatui::style::Color,
endpoints: &[(usize, ratatui::style::Color)],
is_fork: bool,
) -> Vec<(String, ratatui::style::Style)> {
use ratatui::style::Color;
let max_e = endpoints.iter().map(|&(i, _)| i).max().unwrap_or(my_lane);
let lanes_n = active.len().max(max_e + 1);
let width = lanes_n.saturating_mul(2).saturating_sub(1).max(1);
let mut conn = vec![0u8; width];
let mut color = vec![Color::Reset; width];
let endcols: std::collections::HashSet<usize> = endpoints.iter().map(|&(i, _)| i).collect();
for (i, l) in active.iter().enumerate() {
if i == my_lane || endcols.contains(&i) {
continue;
}
if let Some(l) = l {
conn[i * 2] |= DIR_U | DIR_D;
color[i * 2] = l.color;
}
}
conn[my_lane * 2] |= DIR_U | DIR_D | DIR_R;
color[my_lane * 2] = my_color;
for slot in conn[(my_lane * 2 + 1)..(max_e * 2)].iter_mut() {
*slot |= DIR_L | DIR_R;
}
let base = if is_fork {
DIR_D | DIR_L
} else {
DIR_U | DIR_L
};
let mut sorted: Vec<(usize, Color)> = endpoints.to_vec();
sorted.sort_by_key(|&(i, _)| i);
let mut cursor = my_lane * 2; for (e, c) in sorted {
for slot in color[(cursor + 1)..(e * 2)].iter_mut() {
if *slot == Color::Reset {
*slot = c;
}
}
conn[e * 2] |= base;
color[e * 2] = c;
cursor = e * 2;
}
let glyph: Vec<char> = conn.iter().map(|&m| glyph_for(m)).collect();
cells_from(glyph, color)
}
#[cfg(not(feature = "git"))]
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub fn graph_with_base(
_root: &Path,
_base: Option<&str>,
_lang: crate::i18n::Lang,
_refs: Option<&[String]>,
) -> Vec<GraphRow> {
Vec::new()
}
#[cfg(feature = "git")]
fn worktree_payload(root: &Path, lang: crate::i18n::Lang) -> Option<(String, String)> {
let entries = changed_files(root);
if entries.is_empty() {
return None;
}
let (mut staged, mut unstaged, mut untracked) = (0usize, 0usize, 0usize);
for e in &entries {
match e.status {
FileStatus::Untracked => untracked += 1,
_ if e.staged => staged += 1,
_ => unstaged += 1,
}
}
let subject = crate::i18n::tr(lang, crate::i18n::Msg::UncommittedChanges).to_string();
let date = match lang {
crate::i18n::Lang::En => {
format!("{staged} staged · {unstaged} unstaged · {untracked} untracked")
}
crate::i18n::Lang::Jp => {
format!("{staged} ステージ済 · {unstaged} 未ステージ · {untracked} 未追跡")
}
};
Some((subject, date))
}
#[cfg(feature = "git")]
pub fn worktree_diff(root: &Path) -> Vec<DiffLine> {
if !external_git_enabled() {
return Vec::new();
}
let Some(repo) = open_repo(root) else {
let Some(dir) = cli_dir(root) else {
return Vec::new();
};
let head = git_read_token(&dir, ["rev-parse", "--verify", "HEAD^{commit}"]);
return cli_diff_vs_worktree(&dir, head.as_deref());
};
let head_tree = repo
.head()
.ok()
.and_then(|h| h.peel_to_commit().ok())
.and_then(|c| c.tree().ok());
let mut opts = git2::DiffOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.show_untracked_content(true);
let diff = match repo.diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut opts)) {
Ok(d) => d,
Err(_) => return Vec::new(),
};
collect_diff_lines(&diff, true)
}
#[cfg(not(feature = "git"))]
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub fn worktree_diff(_root: &Path) -> Vec<DiffLine> {
Vec::new()
}
#[cfg(feature = "git")]
pub fn diff_since(root: &Path, base: &str) -> Vec<DiffLine> {
if !external_git_enabled() {
return Vec::new();
}
let Some(repo) = open_repo(root) else {
let Some(dir) = cli_dir(root) else {
return Vec::new();
};
let Some(mb) = cli_merge_base(&dir, base) else {
return Vec::new();
};
return cli_diff_vs_worktree(&dir, Some(&mb));
};
let Ok(base_branch) = repo.find_branch(base, git2::BranchType::Local) else {
return Vec::new();
};
let Some(base_oid) = base_branch.get().peel_to_commit().ok().map(|c| c.id()) else {
return Vec::new();
};
let Some(head_oid) = repo
.head()
.ok()
.and_then(|h| h.peel_to_commit().ok())
.map(|c| c.id())
else {
return Vec::new(); };
let Ok(merge_base_oid) = repo.merge_base(base_oid, head_oid) else {
return Vec::new(); };
let Some(base_tree) = repo
.find_commit(merge_base_oid)
.ok()
.and_then(|c| c.tree().ok())
else {
return Vec::new();
};
let mut opts = git2::DiffOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.show_untracked_content(true);
let diff = match repo.diff_tree_to_workdir_with_index(Some(&base_tree), Some(&mut opts)) {
Ok(d) => d,
Err(_) => return Vec::new(),
};
collect_diff_lines(&diff, true)
}
#[cfg(not(feature = "git"))]
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub fn diff_since(_root: &Path, _base: &str) -> Vec<DiffLine> {
Vec::new()
}
#[cfg(feature = "git")]
pub fn merge_base_time(root: &Path, base: &str) -> Option<i64> {
if !external_git_enabled() {
return None;
}
let Some(repo) = open_repo(root) else {
let dir = cli_dir(root)?;
let mb = cli_merge_base(&dir, base)?;
return git_read_token(&dir, ["show", "-s", "--format=%ct", &mb, "--"])?
.parse()
.ok();
};
let base_oid = repo
.find_branch(base, git2::BranchType::Local)
.ok()?
.get()
.peel_to_commit()
.ok()?
.id();
let head_oid = repo.head().ok()?.peel_to_commit().ok()?.id();
let merge_base_oid = repo.merge_base(base_oid, head_oid).ok()?;
repo.find_commit(merge_base_oid)
.ok()
.map(|c| c.time().seconds())
}
#[cfg(feature = "git")]
fn cli_merge_base(dir: &Path, base: &str) -> Option<String> {
let base_oid = git_read_token(
dir,
["rev-parse", "--verify", &format!("{REFS}{base}^{{commit}}")],
)?;
let head_oid = git_read_token(dir, ["rev-parse", "--verify", "HEAD^{commit}"])?;
git_read_token(dir, ["merge-base", &base_oid, &head_oid])
}
#[cfg(not(feature = "git"))]
pub fn merge_base_time(_root: &Path, _base: &str) -> Option<i64> {
None
}
#[cfg(not(feature = "git"))]
pub fn stage(_root: &Path, _file: &Path) -> anyhow::Result<()> {
Err(anyhow::anyhow!("git feature disabled"))
}
#[cfg(not(feature = "git"))]
pub fn unstage(_root: &Path, _file: &Path) -> anyhow::Result<()> {
Err(anyhow::anyhow!("git feature disabled"))
}
#[cfg(not(feature = "git"))]
pub fn stage_all(_root: &Path) -> anyhow::Result<()> {
Err(anyhow::anyhow!("git feature disabled"))
}
#[cfg(not(feature = "git"))]
pub fn unstage_all(_root: &Path) -> anyhow::Result<()> {
Err(anyhow::anyhow!("git feature disabled"))
}
#[cfg(not(feature = "git"))]
pub fn discard(_root: &Path, _file: &Path) -> anyhow::Result<()> {
Err(anyhow::anyhow!("git feature disabled"))
}
#[cfg(not(feature = "git"))]
pub fn commit(_root: &Path, _message: &str) -> anyhow::Result<()> {
Err(anyhow::anyhow!("git feature disabled"))
}
#[cfg(not(feature = "git"))]
pub fn checkout(_root: &Path, _name: &str) -> anyhow::Result<()> {
Err(anyhow::anyhow!("git feature disabled"))
}
#[cfg(not(feature = "git"))]
pub fn create_branch(_root: &Path, _name: &str) -> anyhow::Result<()> {
Err(anyhow::anyhow!("git feature disabled"))
}
#[cfg(not(feature = "git"))]
pub fn delete_branch(_root: &Path, _name: &str, _force: bool) -> anyhow::Result<()> {
Err(anyhow::anyhow!("git feature disabled"))
}
#[cfg(not(feature = "git"))]
pub fn worktree_add(
_root: &Path,
_path: &Path,
_branch: &str,
_create_branch: bool,
) -> anyhow::Result<()> {
Err(anyhow::anyhow!("git feature disabled"))
}
#[cfg(feature = "git")]
pub(crate) fn rollup(
map: &mut HashMap<PathBuf, FileStatus>,
workdir: &Path,
abs: &Path,
st: FileStatus,
) {
map.entry(abs.to_path_buf())
.and_modify(|e| {
if st.rank() > e.rank() {
*e = st;
}
})
.or_insert(st);
let mut cur = abs.parent();
while let Some(dir) = cur {
if !dir.starts_with(workdir) {
break;
}
map.entry(dir.to_path_buf())
.and_modify(|e| {
if st.rank() > e.rank() {
*e = st;
}
})
.or_insert(st);
if dir == workdir {
break;
}
cur = dir.parent();
}
}
#[cfg(all(test, feature = "git"))]
fn classify(s: git2::Status) -> FileStatus {
if s.is_conflicted() {
FileStatus::Conflicted
} else if s.is_wt_deleted() || s.is_index_deleted() {
FileStatus::Deleted
} else if s.is_index_new() {
FileStatus::Added
} else if s.is_wt_new() {
FileStatus::Untracked
} else if s.is_wt_renamed() || s.is_index_renamed() {
FileStatus::Renamed
} else if s.is_wt_modified() || s.is_index_modified() {
FileStatus::Modified
} else if s.is_wt_typechange() || s.is_index_typechange() {
FileStatus::TypeChange
} else {
FileStatus::Modified
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "git")]
use crate::test_support::unique_tmp;
#[test]
fn diff_contents_emits_changed_hunks_with_line_numbers() {
let old = "a\nb\nc\nd\ne\n";
let new = "a\nb\nCHANGED\nd\ne\n";
let lines = diff_contents(old, new);
let removed: Vec<&DiffLine> = lines
.iter()
.filter(|l| l.kind == DiffLineKind::Removed)
.collect();
let added: Vec<&DiffLine> = lines
.iter()
.filter(|l| l.kind == DiffLineKind::Added)
.collect();
assert_eq!(removed.len(), 1, "変更行1本が Removed");
assert_eq!(added.len(), 1, "変更行1本が Added");
assert_eq!(removed[0].text, "c");
assert_eq!(removed[0].old_no, Some(3), "旧側の行番号");
assert_eq!(added[0].text, "CHANGED");
assert_eq!(added[0].new_no, Some(3), "新側の行番号");
assert!(lines
.iter()
.any(|l| l.kind == DiffLineKind::Context && l.text == "a" && l.old_no == Some(1)));
assert!(lines.iter().all(|l| !l.text.contains('\n')));
assert!(diff_contents(new, new).is_empty());
}
#[cfg(feature = "git")]
#[test]
fn background_reads_never_write_the_index() {
let dir = unique_tmp("konoma_no_optional_locks_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let run = |args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {:?}", out);
};
run(&["init", "-q", "."]);
run(&["config", "user.email", "t@t"]);
run(&["config", "user.name", "t"]);
std::fs::write(dir.join("a.txt"), b"same content").unwrap();
run(&["add", "-A"]);
run(&["commit", "-qm", "init"]);
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(dir.join("a.txt"), b"same content").unwrap();
let index = dir.join(".git/index");
let before = std::fs::metadata(&index).unwrap().modified().unwrap();
let st = statuses(&dir);
let ig = ignored(&dir);
let _diff = file_diff(&dir, &dir.join("a.txt"));
let after = std::fs::metadata(&index).unwrap().modified().unwrap();
assert_eq!(before, after, "読み取りで index を書き戻さない");
assert!(
st.is_empty(),
"内容同一なので clean 判定は正しく出る: {st:?}"
);
assert!(ig.is_empty());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn external_git_disabled_returns_empty_for_a_real_repo() {
let dir = unique_tmp("konoma_git_external_disabled_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
let file = dir.join("a.txt");
std::fs::write(&file, b"one\n").unwrap();
assert!(external_git_enabled(), "default is enabled");
assert!(workdir(&dir).is_some(), "sanity: this is a real repo");
stage(&dir, &file).unwrap();
commit(&dir, "init").unwrap();
assert!(branch(&dir).is_some(), "sanity: has a branch after commit");
let commit_id = head_commit_id(&dir).expect("sanity: head resolves to a commit id");
std::fs::write(&file, b"two\n").unwrap();
set_external_git_enabled(false);
assert!(statuses(&dir).is_empty(), "statuses");
assert!(ignored(&dir).is_empty(), "ignored");
assert!(workdir(&dir).is_none(), "workdir");
assert!(git_dir(&dir).is_none(), "git_dir");
assert!(worktree_origin(&dir).is_none(), "worktree_origin");
assert!(branch(&dir).is_none(), "branch");
assert!(branches(&dir).is_empty(), "branches");
assert!(branches_by_recency(&dir).is_empty(), "branches_by_recency");
assert!(worktrees(&dir).is_empty(), "worktrees");
assert!(file_diff(&dir, &file).is_empty(), "file_diff");
assert!(changed_files(&dir).is_empty(), "changed_files");
assert!(log(&dir, 10).is_empty(), "log");
assert!(commit_meta(&dir, &commit_id).is_none(), "commit_meta");
assert!(commit_diff(&dir, &commit_id).is_empty(), "commit_diff");
assert!(worktree_diff(&dir).is_empty(), "worktree_diff");
assert!(head_commit_id(&dir).is_none(), "head_commit_id");
assert!(stage(&dir, &file).is_err(), "stage");
assert!(unstage(&dir, &file).is_err(), "unstage");
assert!(stage_all(&dir).is_err(), "stage_all");
assert!(unstage_all(&dir).is_err(), "unstage_all");
assert!(discard(&dir, &file).is_err(), "discard");
assert!(commit(&dir, "nope").is_err(), "commit");
assert!(checkout(&dir, "nope").is_err(), "checkout");
assert!(create_branch(&dir, "nope").is_err(), "create_branch");
assert!(delete_branch(&dir, "nope", false).is_err(), "delete_branch");
assert!(
worktree_add(&dir, &dir.join("nope-wt"), "nope", true).is_err(),
"worktree_add"
);
assert!(
graph_with_base(&dir, None, crate::i18n::Lang::En, None).is_empty(),
"graph_with_base"
);
assert!(
branch_tip(&dir, "main").is_none() && branch_tip(&dir, "master").is_none(),
"branch_tip"
);
set_external_git_enabled(true);
assert!(
!statuses(&dir).is_empty(),
"statuses work again once re-enabled"
);
assert!(branch(&dir).is_some(), "branch works again once re-enabled");
assert!(
!worktrees(&dir).is_empty(),
"worktrees work again once re-enabled"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn worktrees_parses_main_linked_detached_and_locked_records() {
let dir = unique_tmp("konoma_git_worktrees_parse_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
std::fs::write(dir.join("a.txt"), b"one\n").unwrap();
stage(&dir, &dir.join("a.txt")).unwrap();
commit(&dir, "init").unwrap();
let main_root = dir.canonicalize().unwrap();
let linked = unique_tmp("konoma_git_worktrees_parse_linked");
let detached = unique_tmp("konoma_git_worktrees_parse_detached");
let _ = std::fs::remove_dir_all(&linked);
let _ = std::fs::remove_dir_all(&detached);
let sh = |args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(&main_root)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {out:?}");
};
sh(&[
"worktree",
"add",
"-q",
"-b",
"konoma-wt-feature",
linked.to_str().unwrap(),
]);
sh(&[
"worktree",
"add",
"-q",
"--detach",
detached.to_str().unwrap(),
]);
sh(&[
"worktree",
"lock",
linked.to_str().unwrap(),
"--reason",
"editing",
]);
let list = worktrees(&main_root);
assert!(
list.len() >= 3,
"main + linked + detached の3件以上: {list:?}"
);
assert!(list[0].is_main, "先頭レコードは必ずメインワークツリー");
assert!(
list[0].is_current,
"root=main_root なので main が is_current"
);
assert!(
list[1..].iter().all(|w| !w.is_main),
"2件目以降は is_main=false"
);
let linked_abs = linked.canonicalize().unwrap();
let l = list
.iter()
.find(|w| w.path == linked_abs)
.expect("linked worktree が一覧に無い");
assert_eq!(l.branch.as_deref(), Some("konoma-wt-feature"));
assert!(!l.is_current, "linked は現在地ではない");
assert!(!l.is_main);
assert!(!l.is_bare);
assert_eq!(
l.locked.as_deref(),
Some("editing"),
"lock 理由が読める: {l:?}"
);
assert!(!l.prunable);
assert!(
l.head.as_deref().map(|h| h.len()).unwrap_or(0) <= 7,
"短縮ハッシュは7文字以内: {:?}",
l.head
);
let detached_abs = detached.canonicalize().unwrap();
let d = list
.iter()
.find(|w| w.path == detached_abs)
.expect("detached worktree が一覧に無い");
assert_eq!(d.branch, None, "detached は branch=None");
assert!(d.locked.is_none());
assert!(!d.prunable);
std::fs::remove_dir_all(&detached).ok();
let _ = std::process::Command::new("git")
.current_dir(&main_root)
.args(["worktree", "unlock", linked.to_str().unwrap()])
.output();
std::fs::remove_dir_all(&linked).ok();
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn worktree_add_error_leads_with_gits_fatal_line_not_the_command() {
let dir = unique_tmp("konoma_git_worktree_add_err_msg");
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
std::fs::write(dir.join("a.txt"), b"one\n").unwrap();
stage(&dir, &dir.join("a.txt")).unwrap();
commit(&dir, "init").unwrap();
let root = dir.canonicalize().unwrap();
let first = unique_tmp("konoma_git_worktree_add_err_msg_first");
worktree_add(&root, &first, "taken", true).unwrap();
let second = unique_tmp("konoma_git_worktree_add_err_msg_second");
let err = worktree_add(&root, &second, "taken", false)
.expect_err("同じブランチへの2本目の worktree add は失敗する");
let msg = err.to_string();
assert!(
msg.starts_with("fatal:"),
"理由(fatal:)が先頭に来る: {msg:?}"
);
assert!(
!msg.contains("worktree add"),
"実行したコマンド文字列は含まない(理由を押し出すため): {msg:?}"
);
std::fs::remove_dir_all(&first).ok();
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn worktrees_bare_main_worktree_has_no_checkout_and_gone_checkout_is_prunable() {
let dir = unique_tmp("konoma_git_worktrees_bare_src");
let bare = unique_tmp("konoma_git_worktrees_bare_repo.git");
let wt1 = unique_tmp("konoma_git_worktrees_bare_wt1");
let wt2 = unique_tmp("konoma_git_worktrees_bare_wt2");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&bare);
let _ = std::fs::remove_dir_all(&wt1);
let _ = std::fs::remove_dir_all(&wt2);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
std::fs::write(dir.join("a.txt"), b"one\n").unwrap();
stage(&dir, &dir.join("a.txt")).unwrap();
commit(&dir, "init").unwrap();
let sh = |cwd: &Path, args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {out:?}");
};
sh(
&std::env::temp_dir(),
&[
"clone",
"-q",
"--bare",
dir.to_str().unwrap(),
bare.to_str().unwrap(),
],
);
sh(
&bare,
&[
"worktree",
"add",
"-q",
"-b",
"wt1-branch",
wt1.to_str().unwrap(),
],
);
sh(
&bare,
&[
"worktree",
"add",
"-q",
"-b",
"wt2-branch",
wt2.to_str().unwrap(),
],
);
let list = worktrees(&wt1);
assert_eq!(list.len(), 3, "bare main + wt1 + wt2: {list:?}");
let main = &list[0];
assert!(main.is_main);
assert!(main.is_bare, "bare メインは is_bare=true");
assert_eq!(main.branch, None, "bare メインに branch は無い");
assert_eq!(main.head, None, "bare メインに head は無い");
assert!(!main.prunable);
assert!(!main.is_current, "bare メインは現在地ではない");
let wt1_abs = wt1.canonicalize().unwrap();
let wt2_abs = wt2.canonicalize().unwrap();
let w1 = list.iter().find(|w| w.path == wt1_abs).unwrap();
assert!(!w1.is_bare);
assert_eq!(w1.branch.as_deref(), Some("wt1-branch"));
assert!(!w1.prunable);
assert!(w1.is_current, "root=wt1 なので wt1 が is_current");
let w2 = list.iter().find(|w| w.path == wt2_abs).unwrap();
assert!(!w2.prunable);
assert!(!w2.is_current);
std::fs::remove_dir_all(&wt2).unwrap();
let list2 = worktrees(&wt1);
let gone = list2
.iter()
.find(|w| w.path == wt2_abs)
.expect("消えた worktree もエントリとしては残る");
assert!(gone.prunable, "実体が無ければ prunable: {gone:?}");
let still_here = list2.iter().find(|w| w.path == wt1_abs).unwrap();
assert!(!still_here.prunable, "無事な方は prunable にならない");
std::fs::remove_dir_all(&wt1).ok();
std::fs::remove_dir_all(&bare).ok();
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn worktree_origin_is_none_for_main_and_the_repo_name_for_a_linked_worktree() {
let dir = unique_tmp("konoma_git_worktree_origin_main");
let linked = unique_tmp("konoma_git_worktree_origin_linked");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&linked);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
std::fs::write(dir.join("a.txt"), b"one\n").unwrap();
stage(&dir, &dir.join("a.txt")).unwrap();
commit(&dir, "init").unwrap();
let main_root = dir.canonicalize().unwrap();
let expected_origin = main_root
.file_name()
.and_then(|n| n.to_str())
.unwrap()
.to_string();
assert_eq!(
worktree_origin(&main_root),
None,
"メインの作業ツリーでは None"
);
std::fs::create_dir_all(main_root.join("sub")).unwrap();
assert_eq!(
worktree_origin(&main_root.join("sub")),
None,
"メインのサブディレクトリでも None"
);
let sh = |args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(&main_root)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {out:?}");
};
sh(&[
"worktree",
"add",
"-q",
"-b",
"konoma-wt-origin-feature",
linked.to_str().unwrap(),
]);
let linked_abs = linked.canonicalize().unwrap();
assert_eq!(
worktree_origin(&linked_abs),
Some(expected_origin.clone()),
"リンクワークツリーではメインの repo 名"
);
std::fs::create_dir_all(linked_abs.join("nested")).unwrap();
assert_eq!(
worktree_origin(&linked_abs.join("nested")),
Some(expected_origin),
"リンクワークツリーのサブディレクトリでも同じ値"
);
std::fs::remove_dir_all(&linked).ok();
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn worktree_origin_bare_layout_strips_the_trailing_git_suffix() {
let dir = unique_tmp("konoma_git_worktree_origin_bare_src");
let bare = unique_tmp("konoma_git_worktree_origin_bare_repo").with_extension("git");
let wt = unique_tmp("konoma_git_worktree_origin_bare_wt");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&bare);
let _ = std::fs::remove_dir_all(&wt);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
std::fs::write(dir.join("a.txt"), b"one\n").unwrap();
stage(&dir, &dir.join("a.txt")).unwrap();
commit(&dir, "init").unwrap();
let sh = |cwd: &Path, args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {out:?}");
};
sh(
&std::env::temp_dir(),
&[
"clone",
"-q",
"--bare",
dir.to_str().unwrap(),
bare.to_str().unwrap(),
],
);
sh(
&bare,
&[
"worktree",
"add",
"-q",
"-b",
"wt-branch",
wt.to_str().unwrap(),
],
);
let bare_abs = bare.canonicalize().unwrap();
let expected_origin = bare_abs
.file_name()
.and_then(|n| n.to_str())
.unwrap()
.strip_suffix(".git")
.unwrap()
.to_string();
let wt_abs = wt.canonicalize().unwrap();
assert_eq!(
worktree_origin(&wt_abs),
Some(expected_origin),
"bare レイアウトでは commondir 自身の名前から `.git` を落とした名前"
);
std::fs::remove_dir_all(&wt).ok();
std::fs::remove_dir_all(&bare).ok();
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn diff_since_includes_both_committed_and_uncommitted_changes() {
let dir = unique_tmp("konoma_git_diff_since_both_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
std::fs::write(dir.join("base.txt"), b"base content\n").unwrap();
stage(&dir, &dir.join("base.txt")).unwrap();
commit(&dir, "base commit").unwrap();
let main_root = dir.canonicalize().unwrap();
let base_name = branch(&main_root).expect("sanity: has a branch after the base commit");
let linked = unique_tmp("konoma_git_diff_since_both_linked");
let _ = std::fs::remove_dir_all(&linked);
let sh = |args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(&main_root)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {out:?}");
};
sh(&[
"worktree",
"add",
"-q",
"-b",
"konoma-diff-since-feature",
linked.to_str().unwrap(),
]);
let linked = linked.canonicalize().unwrap();
std::fs::write(linked.join("agent_landed.txt"), b"AGENT_LANDED_MARKER\n").unwrap();
stage(&linked, &linked.join("agent_landed.txt")).unwrap();
commit(&linked, "agent commit inside the worktree").unwrap();
std::fs::write(linked.join("agent_pending.txt"), b"AGENT_PENDING_MARKER\n").unwrap();
let lines = diff_since(&linked, &base_name);
assert!(
!lines.is_empty(),
"base からの積み上げがあるので空のはずがない"
);
let joined: String = lines
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
joined.contains("agent_landed.txt") && joined.contains("AGENT_LANDED_MARKER"),
"コミット済みの変更が含まれる(worktree_diff だけの実装ならここが無い): {joined}"
);
assert!(
joined.contains("agent_pending.txt") && joined.contains("AGENT_PENDING_MARKER"),
"未コミットの変更も含まれる: {joined}"
);
let uncommitted_only = worktree_diff(&linked);
let uncommitted_only_joined: String = uncommitted_only
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
!uncommitted_only_joined.contains("AGENT_LANDED_MARKER")
&& !uncommitted_only_joined.contains("agent_landed.txt"),
"前提の確認: worktree_diff は未コミットのみで、コミット済みの内容は含まれないはず: {uncommitted_only_joined}"
);
std::fs::remove_dir_all(&linked).ok();
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn diff_since_returns_empty_for_a_nonexistent_base() {
let dir = unique_tmp("konoma_git_diff_since_missing_base_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
std::fs::write(dir.join("a.txt"), b"one\n").unwrap();
stage(&dir, &dir.join("a.txt")).unwrap();
commit(&dir, "init").unwrap();
std::fs::write(dir.join("a.txt"), b"two\n").unwrap();
assert!(
diff_since(&dir, "this-branch-does-not-exist").is_empty(),
"存在しない base は空 Vec(フォールバック経路が動く)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn lay_out_lanes_draws_angular_fork_and_merge() {
let dc = |id: &str, parents: &[&str]| DagCommit {
kind: None,
id: id.into(),
parents: parents.iter().map(|s| s.to_string()).collect(),
short: id.into(),
subject: format!("{id} subj"),
author: "a".into(),
date: "d".into(),
refs: String::new(),
};
let commits = vec![
dc("M", &["B", "F"]),
dc("B", &["R"]),
dc("F", &["R"]),
dc("R", &[]),
];
let rows = lay_out_lanes(&commits, None, None, crate::vcs::VcsKind::Git);
let joined: Vec<String> = rows
.iter()
.map(|r| {
r.graph
.iter()
.map(|(s, _)| s.as_str())
.collect::<String>()
.trim_end()
.to_string()
})
.collect();
assert_eq!(
joined,
vec!["◆", "├─┐", "● │", "│ ●", "├─┘", "●"],
"角ばったグラフが期待と不一致: {joined:?}"
);
let all: String = rows
.iter()
.flat_map(|r| r.graph.iter().map(|(s, _)| s.clone()))
.collect();
assert!(
!all.contains('/') && !all.contains('\\'),
"斜め線が残っている: {all}"
);
assert_eq!(all.matches('◆').count(), 1, "マージノード ◆ は1個");
assert_eq!(all.matches('●').count(), 3, "通常ノード ● は3個");
assert_eq!(
rows.iter().filter(|r| r.commit.is_some()).count(),
4,
"コミット行は4"
);
}
#[cfg(feature = "git")]
#[test]
fn lay_out_lanes_multi_converge_uses_tee() {
let dc = |id: &str, parents: &[&str]| DagCommit {
kind: None,
id: id.into(),
parents: parents.iter().map(|s| s.to_string()).collect(),
short: id.into(),
subject: id.into(),
author: "a".into(),
date: "d".into(),
refs: String::new(),
};
let commits = vec![
dc("T1", &["R"]),
dc("T2", &["R"]),
dc("T3", &["R"]),
dc("R", &[]),
];
let rows = lay_out_lanes(&commits, None, None, crate::vcs::VcsKind::Git);
let joined: Vec<String> = rows
.iter()
.map(|r| {
r.graph
.iter()
.map(|(s, _)| s.as_str())
.collect::<String>()
.trim_end()
.to_string()
})
.collect();
assert!(
joined.contains(&"├─┴─┘".to_string()),
"多重合流が ┴ を使っていない(角が水平で潰れた?): {joined:?}"
);
assert!(
!joined.iter().any(|r| r.contains("───")),
"合流の途中が水平で潰れている: {joined:?}"
);
}
#[cfg(feature = "git")]
#[test]
fn lay_out_lanes_base_pins_branch_to_lane0() {
let dc = |id: &str, parents: &[&str]| DagCommit {
kind: None,
id: id.into(),
parents: parents.iter().map(|s| s.to_string()).collect(),
short: id.into(),
subject: id.into(),
author: "a".into(),
date: "d".into(),
refs: String::new(),
};
let commits = vec![
dc("A1", &["A2"]),
dc("A2", &["R"]),
dc("B1", &["B2"]),
dc("B2", &["R"]),
dc("R", &[]),
];
let node_at_col0 = |rows: &[GraphRow], id: &str| -> bool {
rows.iter().any(|r| {
r.commit.as_deref() == Some(id)
&& matches!(r.graph.first(), Some((s, _)) if s == "●" || s == "◆")
})
};
let none = lay_out_lanes(&commits, None, None, crate::vcs::VcsKind::Git);
assert!(node_at_col0(&none, "A1"), "base なしでは A1 が lane0");
assert!(
!node_at_col0(&none, "B1"),
"base なしでは B1 は lane0 でない"
);
let based = lay_out_lanes(&commits, Some("B1"), None, crate::vcs::VcsKind::Git);
assert!(
node_at_col0(&based, "B1"),
"base=B1 で B1 が lane0: {based:?}",
);
assert!(
node_at_col0(&based, "B2"),
"base=B1 で B2 も lane0(first-parent 継続)"
);
assert!(
!node_at_col0(&based, "A1"),
"base=B1 で A1 は右レーン(lane0 でない)"
);
assert_eq!(
based.iter().filter(|r| r.commit.is_some()).count(),
5,
"全コミットが残る(--all 相当)"
);
}
#[cfg(feature = "git")]
#[test]
fn worktree_row_sits_on_head_lane_not_always_col0() {
let dc = |id: &str, parents: &[&str], refs: &str| DagCommit {
kind: None,
id: id.into(),
parents: parents.iter().map(|s| s.to_string()).collect(),
short: id.into(),
subject: id.into(),
author: "a".into(),
date: "d".into(),
refs: refs.into(),
};
let commits = vec![
dc("A1", &["A2"], "HEAD -> feature"),
dc("A2", &["R"], ""),
dc("B1", &["B2"], ""),
dc("B2", &["R"], ""),
dc("R", &[], ""),
];
let wt = Some(("Uncommitted changes".to_string(), "1 staged".to_string()));
let rows = lay_out_lanes(&commits, Some("B1"), wt.clone(), crate::vcs::VcsKind::Git);
let wt_row = rows.iter().find(|r| r.worktree).expect("WT 行がある");
assert!(wt_row.commit.is_none(), "WT 行は commit=None");
assert!(
!matches!(wt_row.graph.first(), Some((s, _)) if s == "●"),
"base が別枝なら WT は col0 に固定されない"
);
assert!(
wt_row.graph.iter().any(|(s, _)| s == "●"),
"WT 行に ● ノードがある"
);
let col_of_node = |r: &GraphRow| r.graph.iter().position(|(s, _)| s == "●" || s == "◆");
let wt_idx = rows.iter().position(|r| r.worktree).unwrap();
let head_idx = rows
.iter()
.position(|r| r.commit.as_deref() == Some("A1"))
.unwrap();
assert_eq!(
col_of_node(&rows[wt_idx]),
col_of_node(&rows[head_idx]),
"WT のノード列が HEAD(A1)のノード列と一致"
);
let rows0 = lay_out_lanes(&commits, None, wt, crate::vcs::VcsKind::Git);
let wt0 = rows0.iter().find(|r| r.worktree).unwrap();
assert!(
matches!(wt0.graph.first(), Some((s, _)) if s == "●"),
"base なしでは WT は col0(HEAD=lane0)"
);
}
#[test]
fn marker_and_rank_are_distinct() {
let all = [
FileStatus::Modified,
FileStatus::Added,
FileStatus::Untracked,
FileStatus::Deleted,
FileStatus::Renamed,
FileStatus::TypeChange,
FileStatus::Conflicted,
];
let markers: Vec<char> = all.iter().map(|s| s.marker()).collect();
let mut uniq = markers.clone();
uniq.sort_unstable();
uniq.dedup();
assert_eq!(markers.len(), uniq.len(), "マーカーが重複");
assert!(FileStatus::Conflicted.rank() > FileStatus::Modified.rank());
assert!(FileStatus::Modified.rank() > FileStatus::Untracked.rank());
}
#[cfg(feature = "git")]
#[test]
fn classify_maps_status_bits() {
use git2::Status as S;
assert_eq!(classify(S::INDEX_RENAMED), FileStatus::Renamed);
assert_eq!(classify(S::WT_NEW), FileStatus::Untracked);
assert_eq!(classify(S::INDEX_NEW), FileStatus::Added);
assert_eq!(classify(S::WT_MODIFIED), FileStatus::Modified);
assert_eq!(classify(S::WT_DELETED), FileStatus::Deleted);
assert_eq!(classify(S::CONFLICTED), FileStatus::Conflicted);
assert_eq!(classify(S::WT_TYPECHANGE), FileStatus::TypeChange);
}
#[cfg(feature = "git")]
#[test]
fn untracked_file_and_dir_rollup_detected() {
let dir = unique_tmp("konoma_git_status_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
git2::Repository::init(&dir).unwrap();
std::fs::write(dir.join("sub").join("foo.txt"), b"hi").unwrap();
let map = statuses(&dir);
let canon = dir.canonicalize().unwrap();
assert_eq!(
map.get(&canon.join("sub").join("foo.txt")),
Some(&FileStatus::Untracked)
);
assert_eq!(map.get(&canon.join("sub")), Some(&FileStatus::Untracked));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn rename_is_detected_on_new_path() {
let dir = unique_tmp("konoma_git_status_rename");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
std::fs::write(dir.join("old.txt"), b"content here\n").unwrap();
stage(&dir, &dir.join("old.txt")).unwrap();
commit(&dir, "init").unwrap();
std::process::Command::new("git")
.current_dir(&dir)
.args(["mv", "old.txt", "new.txt"])
.output()
.unwrap();
let map = statuses(&dir);
let canon = dir.canonicalize().unwrap();
assert_eq!(
map.get(&canon.join("new.txt")),
Some(&FileStatus::Renamed),
"改名は新パスに R が付くはず: {map:?}"
);
assert!(
!map.contains_key(&canon.join("old.txt")),
"旧パスはツリーに出ないので map に入らないはず"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn git_marker_dir_finds_dir_file_and_ancestor_markers() {
let base = unique_tmp("konoma_marker_walk_test");
let _ = std::fs::remove_dir_all(&base);
let as_dir = base.join("as_dir");
std::fs::create_dir_all(as_dir.join(".git")).unwrap();
std::fs::create_dir_all(as_dir.join("sub/deep")).unwrap();
let as_file = base.join("as_file");
std::fs::create_dir_all(as_file.join("sub")).unwrap();
std::fs::write(
as_file.join(".git"),
b"gitdir: /elsewhere/.git/worktrees/x\n",
)
.unwrap();
let plain = base.join("plain/inner");
std::fs::create_dir_all(&plain).unwrap();
assert_eq!(git_marker_dir(&as_dir).as_deref(), Some(as_dir.as_path()));
assert_eq!(
git_marker_dir(&as_dir.join("sub/deep")).as_deref(),
Some(as_dir.as_path()),
"祖先の .git ディレクトリを見つける"
);
assert_eq!(
git_marker_dir(&as_file.join("sub")).as_deref(),
Some(as_file.as_path()),
".git が**ファイル**(リンクワークツリー)でも marker"
);
assert_eq!(
git_marker_dir(&plain),
None,
"どこにも .git が無ければ「repo ではない」と即断する"
);
std::fs::remove_dir_all(&base).ok();
}
#[cfg(feature = "git")]
#[test]
fn an_ordinary_directory_never_spawns_a_discovery_process() {
let dir = unique_tmp("konoma_no_spawn_outside_repo_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("a.txt"), b"hi\n").unwrap();
set_git_binary_available_for_test(Some(true));
let (_, calls) = count_discovery_cli_calls(|| {
for probe in [dir.clone(), dir.join("sub")] {
assert!(workdir(&probe).is_none());
assert!(git_dir(&probe).is_none());
assert!(branch(&probe).is_none());
assert!(worktree_origin(&probe).is_none());
assert!(statuses(&probe).is_empty());
assert!(ignored(&probe).is_empty());
assert!(changed_files(&probe).is_empty());
assert!(worktrees(&probe).is_empty());
assert!(log(&probe, 10).is_empty());
assert!(branches(&probe).is_empty());
assert!(branch_tip(&probe, "main").is_none());
assert!(commit_meta(&probe, "abc123").is_none());
assert!(commit_diff(&probe, "abc123").is_empty());
assert!(graph_with_base(&probe, None, crate::i18n::Lang::En, None).is_empty());
}
});
set_git_binary_available_for_test(None);
assert_eq!(
calls, 0,
"repo でないディレクトリで発見用の子プロセスを起動してはいけない"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
fn init_reftable_repo(dir: &Path) -> bool {
let run = |args: &[&str]| -> bool {
std::process::Command::new("git")
.current_dir(dir)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
if !run(&["init", "--ref-format=reftable", "-q", "."]) {
return false; }
run(&["config", "user.email", "t@example.com"]);
run(&["config", "user.name", "Test"]);
std::fs::write(dir.join("tracked.txt"), b"one\n").unwrap();
run(&["add", "-A"]) && run(&["commit", "-qm", "init"])
}
#[cfg(feature = "git")]
#[test]
fn reftable_repository_keeps_status_branch_and_worktree_features() {
let dir = unique_tmp("konoma_reftable_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
if !init_reftable_repo(&dir) {
eprintln!("skip: この git は --ref-format=reftable を作れない (git < 2.45)");
std::fs::remove_dir_all(&dir).ok();
return;
}
let canon = dir.canonicalize().unwrap();
let libgit2_refuses = git2::Repository::discover(&dir).is_err();
assert_eq!(
std::fs::read_to_string(canon.join(".git/HEAD"))
.unwrap()
.trim(),
"ref: refs/heads/.invalid",
"reftable の .git/HEAD は後方互換のプレースホルダ(嘘のブランチ名)"
);
std::fs::write(canon.join("tracked.txt"), b"two\n").unwrap();
std::fs::write(canon.join("fresh.txt"), b"new\n").unwrap();
assert_eq!(
workdir(&dir).as_deref(),
Some(canon.as_path()),
"workdir が作業ツリーの根を返す"
);
assert_eq!(
git_dir(&dir).as_deref(),
Some(canon.join(".git").as_path()),
"git_dir"
);
std::fs::create_dir_all(canon.join("sub")).unwrap();
assert_eq!(
workdir(&canon.join("sub")).as_deref(),
Some(canon.as_path())
);
let st = statuses(&dir);
assert_eq!(
st.get(&canon.join("tracked.txt")),
Some(&FileStatus::Modified),
"変更が M として出る: {st:?}"
);
assert_eq!(
st.get(&canon.join("fresh.txt")),
Some(&FileStatus::Untracked),
"未追跡が U として出る: {st:?}"
);
assert_eq!(
changed_files(&dir).len(),
2,
"変更ファイル一覧 (Git ハブ) も出る"
);
assert!(
!worktrees(&dir).is_empty(),
"worktree 一覧 (CLI 経路) も出る"
);
let b = branch(&dir).expect("ブランチ名が取れる");
assert!(
b == "main" || b == "master",
"既定ブランチ名が取れる: {b:?}"
);
assert!(
!b.contains(".invalid"),
".git/HEAD のプレースホルダを読んではいけない: {b:?}"
);
assert_eq!(
worktree_origin(&dir),
None,
"メインワークツリーなので WT チップは出さない"
);
if libgit2_refuses {
let (_, calls) = count_discovery_cli_calls(|| {
for _ in 0..20 {
let _ = workdir(&dir);
let _ = git_dir(&dir);
let _ = statuses(&dir);
}
});
assert_eq!(
calls, 0,
"発見結果はキャッシュ済み — fs イベント毎に rev-parse を起動してはいけない"
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn reftable_repository_serves_the_object_database_reads_over_the_cli() {
let dir = unique_tmp("konoma_reftable_reads_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
if !init_reftable_repo(&dir) {
eprintln!("skip: この git は --ref-format=reftable を作れない (git < 2.45)");
std::fs::remove_dir_all(&dir).ok();
return;
}
let canon = dir.canonicalize().unwrap();
if git2::Repository::discover(&dir).is_ok() {
eprintln!("skip: この git2 は reftable を開ける (フォールバック経路が走らない)");
std::fs::remove_dir_all(&dir).ok();
return;
}
let run = |args: &[&str]| {
assert!(std::process::Command::new("git")
.current_dir(&canon)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false));
};
std::fs::write(canon.join("tracked.txt"), b"one\ntwo\n").unwrap();
run(&["add", "-A"]);
run(&["commit", "-qm", "second subject\n\nbody line"]);
run(&["branch", "sidebranch"]);
std::fs::write(canon.join("same.txt"), b"x\n").unwrap();
run(&["add", "-A"]);
run(&["commit", "-qm", "third"]);
std::fs::write(canon.join("tracked.txt"), b"one\nTWO\n").unwrap();
std::fs::write(canon.join("fresh.txt"), b"brand new\n").unwrap();
let entries = log(&dir, 10);
assert_eq!(entries.len(), 3, "log がコミットを返す: {entries:?}");
assert_eq!(entries[0].summary, "third", "件名 (newest first)");
assert_eq!(entries[1].summary, "second subject");
assert_eq!(entries[2].summary, "init");
assert_eq!(entries[0].author, "Test");
assert!(entries[0].time_epoch > 0, "コミット時刻");
assert_eq!(entries[0].short, entries[0].id[..7], "短縮ハッシュ");
assert_eq!(log(&dir, 2).len(), 2, "max を尊重する");
let meta = commit_meta(&dir, &entries[1].id).expect("commit_meta");
assert_eq!(meta.author, "Test");
assert_eq!(
meta.message, "second subject\n\nbody line",
"本文の改行を保った完全メッセージ"
);
assert_eq!(meta.short, entries[1].id[..7]);
assert!(!meta.date.is_empty());
assert!(
commit_meta(&dir, "0000000000000000000000000000000000000000").is_none(),
"存在しないコミットは None"
);
let names: Vec<String> = branches(&dir).iter().map(|b| b.name.clone()).collect();
assert!(
names.iter().any(|n| n == "sidebranch"),
"ブランチ名が出る: {names:?}"
);
assert_eq!(
branches(&dir).iter().filter(|b| b.is_current).count(),
1,
"現在のブランチがちょうど1つ"
);
let by_rec = branches_by_recency(&dir);
assert_eq!(by_rec.len(), names.len(), "同じ集合を返す");
assert!(
by_rec.iter().all(|(_, _, t)| *t > 0),
"tip の時刻: {by_rec:?}"
);
let head = head_commit_id(&dir).expect("head_commit_id");
assert_eq!(head, entries[0].id, "HEAD は log の先頭と一致");
assert_eq!(
branch_tip(&dir, "sidebranch").as_deref(),
Some(entries[1].id.as_str()),
"sidebranch は真ん中のコミットを指したまま (HEAD ではない)"
);
assert!(branch_tip(&dir, "no-such-branch").is_none());
let blob = blob_at(&dir, &head, &canon.join("tracked.txt")).expect("blob_at");
assert_eq!(
blob, b"one\ntwo\n",
"作業ツリーの現在値ではなくコミット時点の中身"
);
assert!(
blob_at(&dir, &head, &canon.join("fresh.txt")).is_none(),
"そのコミットに無いファイルは None"
);
let d = file_diff(&dir, &canon.join("tracked.txt"));
assert!(
d.iter()
.any(|l| l.kind == DiffLineKind::Removed && l.text == "two"),
"変更前の行: {d:?}"
);
assert!(
d.iter()
.any(|l| l.kind == DiffLineKind::Added && l.text == "TWO"),
"変更後の行: {d:?}"
);
let fresh = file_diff(&dir, &canon.join("fresh.txt"));
assert!(
fresh.iter().all(|l| l.kind == DiffLineKind::Added)
&& fresh.iter().any(|l| l.text == "brand new"),
"未追跡ファイルは全行追加: {fresh:?}"
);
assert!(
file_diff(&dir, &canon.join("same.txt")).is_empty(),
"変更の無いファイルは空"
);
let wt = worktree_diff(&dir);
assert!(
wt.iter()
.any(|l| l.old_no.is_none() && l.new_no.is_none() && l.text == "fresh.txt"),
"ファイル境界ヘッダ (未追跡も含む): {wt:?}"
);
assert!(
wt.iter()
.any(|l| l.kind == DiffLineKind::Added && l.text == "TWO"),
"未コミットの変更行"
);
let cd = commit_diff(&dir, &entries[1].id);
assert!(
cd.iter()
.any(|l| l.old_no.is_none() && l.new_no.is_none() && l.text == "tracked.txt"),
"コミット差分のヘッダ: {cd:?}"
);
assert!(
cd.iter()
.any(|l| l.kind == DiffLineKind::Added && l.text == "two"),
"親コミットに対する追加行: {cd:?}"
);
let root_commit = commit_diff(&dir, &entries[2].id);
assert!(
root_commit
.iter()
.any(|l| l.kind == DiffLineKind::Added && l.text == "one"),
"ルートコミットは全行追加 (--root): {root_commit:?}"
);
let since = diff_since(&dir, "sidebranch");
assert!(
since
.iter()
.any(|l| l.kind == DiffLineKind::Added && l.text == "TWO"),
"merge-base 以降 = コミット済み+未コミット: {since:?}"
);
assert!(
since.iter().any(|l| l.text == "same.txt"),
"分岐後にコミットしたファイルも含む: {since:?}"
);
assert!(
diff_since(&dir, "no-such-branch").is_empty(),
"存在しない base は空"
);
let t = merge_base_time(&dir, "sidebranch").expect("merge_base_time");
assert_eq!(t, entries[1].time_epoch, "merge-base は分岐元のコミット");
assert!(merge_base_time(&dir, "no-such-branch").is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn the_libgit2_path_never_spawns_a_read_fallback() {
let dir = unique_tmp("konoma_no_read_fallback_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
let canon = dir.canonicalize().unwrap();
std::fs::write(canon.join("a.txt"), b"one\n").unwrap();
commit_all(&dir, "init");
std::fs::write(canon.join("a.txt"), b"two\n").unwrap();
let head = head_commit_id(&dir).expect("head");
let branch_name = branch(&dir).expect("branch");
let (_, calls) = count_read_cli_calls(|| {
for _ in 0..3 {
let _ = file_diff(&dir, &canon.join("a.txt"));
let _ = log(&dir, 10);
let _ = commit_diff(&dir, &head);
let _ = commit_meta(&dir, &head);
let _ = branches(&dir);
let _ = branches_by_recency(&dir);
let _ = branch_tip(&dir, &branch_name);
let _ = head_commit_id(&dir);
let _ = blob_at(&dir, &head, &canon.join("a.txt"));
let _ = worktree_diff(&dir);
let _ = diff_since(&dir, &branch_name);
let _ = merge_base_time(&dir, &branch_name);
}
});
assert_eq!(
calls, 0,
"libgit2 が開ける repo でフォールバックの子プロセスを起動してはいけない"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn cat_file_batch_answers_every_spec_in_one_process() {
let dir = unique_tmp("konoma_cat_file_batch_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
let canon = dir.canonicalize().unwrap();
let text = b"first\nsecond\nthird\n".to_vec();
let binary = vec![0u8, b'\n', 1, 2, 0, b'\n', 255, 0];
let spaced = "a file blob 12 name.txt";
std::fs::write(canon.join("text.txt"), &text).unwrap();
std::fs::write(canon.join("bin.dat"), &binary).unwrap();
std::fs::write(canon.join(spaced), &binary).unwrap();
commit_all(&dir, "init");
let head = head_commit_id(&dir).expect("head");
let specs = vec![
blob_spec(&head, Path::new("text.txt")),
blob_spec(&head, Path::new("no-such-file.txt")), blob_spec(&head, Path::new("bin.dat")),
blob_spec(&head, Path::new(spaced)),
blob_spec(&head, Path::new("no such blob 9 file")), blob_spec(&head, Path::new("also-missing")), ];
let (got, calls) = count_read_cli_calls(|| cat_file_batch(&canon, &specs));
assert_eq!(calls, 1, "specs 6 個でも cat-file の起動は 1 回");
assert_eq!(got.len(), specs.len(), "スロットは spec と 1:1");
assert_eq!(got[0].as_deref(), Some(&text[..]), "テキストの中身");
assert_eq!(got[1], None, "存在しないパスは missing → None");
assert_eq!(
got[2].as_deref(),
Some(&binary[..]),
"NUL と改行を含むバイナリがバイト単位で保たれる"
);
assert_eq!(
got[3].as_deref(),
Some(&binary[..]),
"空白入りのファイル名でも読める"
);
assert_eq!(got[4], None, "空白だらけの missing 行を中身と取り違えない");
assert_eq!(got[5], None, "末尾の missing でも枠がずれない");
std::fs::create_dir_all(canon.join("sub")).unwrap();
std::fs::write(canon.join("sub/inner.txt"), b"inner\n").unwrap();
commit_all(&dir, "second");
let head2 = head_commit_id(&dir).expect("head2");
let mixed = vec![
blob_spec(&head2, Path::new("sub")),
blob_spec(&head2, Path::new("sub/inner.txt")),
];
let got = cat_file_batch(&canon, &mixed);
assert_eq!(got[0], None, "ディレクトリ (tree) は blob ではない → None");
assert_eq!(
got[1].as_deref(),
Some(&b"inner\n"[..]),
"tree を読み飛ばしても次のスロットがずれない"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn cat_file_batch_does_not_deadlock_when_both_pipes_fill() {
let dir = unique_tmp("konoma_cat_file_batch_pipe_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
let canon = dir.canonicalize().unwrap();
let name = format!("{}.txt", "a-fairly-long-file-name-".repeat(2));
let body = b"payload line one\npayload line two\n".to_vec();
std::fs::write(canon.join(&name), &body).unwrap();
let big: Vec<u8> = (0..300_000u32).map(|i| (i % 251) as u8).collect();
std::fs::write(canon.join("big.dat"), &big).unwrap();
commit_all(&dir, "init");
let head = head_commit_id(&dir).expect("head");
let mut specs: Vec<_> = (0..6000)
.map(|_| blob_spec(&head, Path::new(&name)))
.collect();
specs.insert(3000, blob_spec(&head, Path::new("big.dat")));
let (got, calls) = count_read_cli_calls(|| cat_file_batch(&canon, &specs));
assert_eq!(calls, 1, "6001 spec でも起動は 1 回");
assert_eq!(got.len(), specs.len());
assert_eq!(got[3000].as_deref(), Some(&big[..]), "大きい blob の中身");
assert!(
got.iter()
.enumerate()
.filter(|(i, _)| *i != 3000)
.all(|(_, b)| b.as_deref() == Some(&body[..])),
"全スロットが正しい中身で埋まる (途中で打ち切られていない)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn a_multi_file_diff_spawns_one_cat_file_whatever_the_file_count() {
let dir = unique_tmp("konoma_batch_spawn_count_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
let canon = dir.canonicalize().unwrap();
let write_n = |n: usize, tag: &str| {
for i in 0..n {
std::fs::write(canon.join(format!("f{i}.txt")), format!("{tag} {i}\n")).unwrap();
}
};
write_n(12, "one");
commit_all(&dir, "init");
let small = head_commit_id(&dir).expect("head");
write_n(3, "two");
commit_all(&dir, "three files");
let three = head_commit_id(&dir).expect("head");
write_n(12, "three");
commit_all(&dir, "twelve files");
let twelve = head_commit_id(&dir).expect("head");
let _ = cli_dir(&dir);
let (lines3, calls3) = count_read_cli_calls(|| commit_diff_via_cli(&dir, &three));
let (lines12, calls12) = count_read_cli_calls(|| commit_diff_via_cli(&dir, &twelve));
assert_eq!(calls3, 2, "3 ファイル: diff-tree 1 + cat-file 1");
assert_eq!(
calls12, 2,
"12 ファイル: ファイル数が増えても起動数は変わらない"
);
assert!(
!lines3.is_empty() && lines12.len() > lines3.len(),
"比較が空同士で成立していない: {} vs {}",
lines3.len(),
lines12.len()
);
write_n(12, "dirty");
let (wt, calls_wt) =
count_read_cli_calls(|| cli_diff_vs_worktree(&canon, Some(small.as_str())));
assert!(!wt.is_empty(), "作業ツリー差分が空でない");
assert_eq!(
calls_wt, 3,
"listing 2 (diff --name-only + ls-files --others) + cat-file 1 — \
変更 12 ファイルでもこれ以上増えない"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn the_cli_fallback_and_libgit2_describe_the_same_repository() {
let dir = unique_tmp("konoma_fallback_parity_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
let canon = dir.canonicalize().unwrap();
let body: String = (1..=40).map(|i| format!("line {i}\n")).collect();
std::fs::write(canon.join("a.txt"), &body).unwrap();
std::fs::write(canon.join("b.txt"), b"kept\n").unwrap();
commit_all(&dir, "init");
let changed = body.replace("line 3\n", "LINE THREE\n") + "appended\n";
std::fs::write(canon.join("a.txt"), &changed).unwrap();
std::fs::write(canon.join("added.txt"), b"fresh\n").unwrap();
let changes = |lines: &[DiffLine]| -> Vec<String> {
let mut v: Vec<String> = lines
.iter()
.filter(|l| l.kind != DiffLineKind::Context)
.map(|l| {
let sign = if l.kind == DiffLineKind::Added {
'+'
} else {
'-'
};
format!("{sign}{}", l.text)
})
.collect();
v.sort();
v
};
let headers = |lines: &[DiffLine]| -> Vec<String> {
lines
.iter()
.filter(|l| l.old_no.is_none() && l.new_no.is_none())
.map(|l| l.text.clone())
.collect()
};
let file = canon.join("a.txt");
assert_eq!(
changes(&file_diff(&dir, &file)),
changes(&file_diff_via_cli(&dir, &file)),
"file_diff の変更行が一致する"
);
assert!(
!changes(&file_diff_via_cli(&dir, &file)).is_empty(),
"比較が空同士で成立していない"
);
let libgit2_wt = worktree_diff(&dir);
let cli_wt = cli_diff_vs_worktree(&canon, head_commit_id(&dir).as_deref());
assert_eq!(
changes(&libgit2_wt),
changes(&cli_wt),
"worktree_diff の変更行 (未追跡込み) が一致する"
);
assert_eq!(
headers(&libgit2_wt),
headers(&cli_wt),
"ファイル境界ヘッダの並びが一致する"
);
let head = head_commit_id(&dir).expect("head");
assert_eq!(
head,
git_read_token(&canon, ["rev-parse", "--verify", "HEAD^{commit}"]).unwrap(),
"head_commit_id"
);
assert_eq!(
blob_at(&dir, &head, &file),
cli_blob(&canon, &head, Path::new("a.txt")),
"blob_at の中身"
);
assert!(
!changes(&commit_diff(&dir, &head)).is_empty(),
"比較が空同士で成立していない"
);
assert_eq!(
changes(&commit_diff(&dir, &head)),
changes(&commit_diff_via_cli(&dir, &head)),
"commit_diff の変更行が一致する"
);
assert_eq!(
log(&dir, 10)
.iter()
.map(|c| (
c.id.clone(),
c.summary.clone(),
c.author.clone(),
c.time_epoch
))
.collect::<Vec<_>>(),
log_via_cli(&dir, 10)
.iter()
.map(|c| (
c.id.clone(),
c.summary.clone(),
c.author.clone(),
c.time_epoch
))
.collect::<Vec<_>>(),
"log のコミット列"
);
let m1 = commit_meta(&dir, &head).unwrap();
let m2 = commit_meta_via_cli(&dir, &head).unwrap();
assert_eq!(
(m1.id, m1.short, m1.author, m1.date, m1.message),
(m2.id, m2.short, m2.author, m2.date, m2.message),
"commit_meta の全項目"
);
assert_eq!(
branches(&dir)
.iter()
.map(|b| (b.name.clone(), b.is_current))
.collect::<Vec<_>>(),
branches_via_cli(&dir)
.iter()
.map(|b| (b.name.clone(), b.is_current))
.collect::<Vec<_>>(),
"branches"
);
assert_eq!(
branches_by_recency(&dir),
branches_by_recency_via_cli(&dir),
"branches_by_recency"
);
let name = branch(&dir).unwrap();
assert_eq!(
branch_tip(&dir, &name),
branch_tip_via_cli(&dir, &name),
"branch_tip"
);
assert_eq!(
merge_base_time(&dir, &name),
git_read_token(
&canon,
[
"show",
"-s",
"--format=%ct",
&cli_merge_base(&canon, &name).unwrap(),
"--"
]
)
.and_then(|s| s.parse::<i64>().ok()),
"merge_base_time"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn binary_files_contribute_a_header_but_no_lines() {
let mut out = Vec::new();
push_file_diff(
&mut out,
Path::new("logo.png"),
Some(b"\x89PNG\r\n\x1a\n\x00\x00old"),
Some(b"\x89PNG\r\n\x1a\n\x00\x00new"),
true,
);
assert_eq!(out.len(), 1, "ヘッダ1行のみ: {out:?}");
assert_eq!(out[0].text, "logo.png");
assert!(out[0].old_no.is_none() && out[0].new_no.is_none());
let mut none = Vec::new();
push_file_diff(
&mut none,
Path::new("logo.png"),
Some(b"\x00a"),
None,
false,
);
assert!(none.is_empty(), "ヘッダ無しならバイナリは何も出さない");
let mut text = Vec::new();
push_file_diff(
&mut text,
Path::new("t.txt"),
Some(b"a\n"),
Some(b"\xff\n"),
false,
);
assert!(
text.iter().any(|l| l.kind == DiffLineKind::Added),
"非 UTF-8 のテキストも差分になる: {text:?}"
);
}
#[cfg(feature = "git")]
fn init_repo(dir: &Path) {
let repo = git2::Repository::init(dir).unwrap();
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "Test").unwrap();
cfg.set_str("user.email", "test@example.com").unwrap();
cfg.set_str("commit.gpgsign", "false").ok();
}
#[cfg(feature = "git")]
fn commit_all(dir: &Path, msg: &str) {
stage_all(dir).unwrap();
commit(dir, msg).unwrap();
}
#[cfg(all(feature = "git", target_os = "macos"))]
#[test]
fn nfd_named_file_status_is_found_via_the_tree_path() {
let dir = unique_tmp("konoma_nfd_status_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
let nfd_name = "\u{304B}\u{3099}_nfd.txt";
let canon = dir.canonicalize().unwrap();
let nfd_path = canon.join(nfd_name);
std::fs::write(&nfd_path, b"original\n").unwrap();
stage(&dir, &nfd_path).unwrap();
commit(&dir, "init").unwrap();
std::fs::write(&nfd_path, b"original\nchanged\n").unwrap();
let out = std::process::Command::new("git")
.current_dir(&dir)
.args(["status", "--porcelain=v1", "-z"])
.output()
.unwrap();
let porcelain = String::from_utf8_lossy(&out.stdout);
let nfc_name = "\u{304C}_nfd.txt"; assert!(
porcelain.contains(nfc_name),
"テストの前提(core.precomposeunicode): git status は NFC で報告するはず: {porcelain:?}"
);
assert!(
!porcelain.contains(nfd_name),
"テストの前提: porcelain 出力に NFD 表記は含まれないはず: {porcelain:?}"
);
let map = statuses(&dir);
assert!(
map.contains_key(&nfd_path),
"ツリー側の NFD パスで status が引ける必要がある: {map:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(all(feature = "git", target_os = "macos"))]
#[test]
fn ascii_and_nfc_named_files_are_unaffected_by_nfd_normalization() {
let dir = unique_tmp("konoma_nfd_status_siblings_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
let canon = dir.canonicalize().unwrap();
let ascii_path = canon.join("ascii.txt");
let nfc_path = canon.join("\u{304C}_nfc.txt");
std::fs::write(&ascii_path, b"one\n").unwrap();
std::fs::write(&nfc_path, b"two\n").unwrap();
stage(&dir, &ascii_path).unwrap();
stage(&dir, &nfc_path).unwrap();
commit(&dir, "init").unwrap();
std::fs::write(&ascii_path, b"one\nmodified\n").unwrap();
std::fs::write(&nfc_path, b"two\nmodified\n").unwrap();
let map = statuses(&dir);
assert_eq!(
map.get(&ascii_path),
Some(&FileStatus::Modified),
"ASCII 名は非退行: {map:?}"
);
assert_eq!(
map.get(&nfc_path),
Some(&FileStatus::Modified),
"NFC 名は非退行: {map:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(all(feature = "git", target_os = "macos"))]
#[test]
fn deleted_nfd_originated_file_falls_back_to_the_porcelain_path() {
let dir = unique_tmp("konoma_nfd_status_deleted_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
let canon = dir.canonicalize().unwrap();
let nfd_name = "\u{304B}\u{3099}_nfd.txt";
let nfd_path = canon.join(nfd_name);
std::fs::write(&nfd_path, b"original\n").unwrap();
stage(&dir, &nfd_path).unwrap();
commit(&dir, "init").unwrap();
std::fs::remove_file(&nfd_path).unwrap();
let map = statuses(&dir);
let nfc_path = canon.join("\u{304C}_nfd.txt");
assert_eq!(
map.get(&nfc_path),
Some(&FileStatus::Deleted),
"canonicalize 失敗時は元の(NFC)パスにフォールバックし、Deleted のまま残る必要がある: {map:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn ignored_collapses_dirs_and_excludes_tracked() {
let dir = unique_tmp("konoma_git_ignored_set");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
std::fs::write(dir.join(".gitignore"), b"target/\nnode_modules/\n*.log\n").unwrap();
std::fs::create_dir_all(dir.join("target/deep")).unwrap();
std::fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("target/a.o"), b"x").unwrap();
std::fs::write(dir.join("target/deep/b.o"), b"x").unwrap();
std::fs::write(dir.join("node_modules/pkg/index.js"), b"x").unwrap();
std::fs::write(dir.join("app.log"), b"x").unwrap();
std::fs::write(dir.join("src/main.rs"), b"fn main(){}\n").unwrap();
stage(&dir, &dir.join(".gitignore")).unwrap();
stage(&dir, &dir.join("src/main.rs")).unwrap();
commit(&dir, "init").unwrap();
let set = ignored(&dir);
let canon = dir.canonicalize().unwrap();
assert!(
set.contains(&canon.join("target")),
"target/ が collapse で1件: {set:?}"
);
assert!(
set.contains(&canon.join("node_modules")),
"node_modules/ が collapse で1件: {set:?}"
);
assert!(
set.contains(&canon.join("app.log")),
"*.log の無視ファイル: {set:?}"
);
assert!(
!set.contains(&canon.join("target/a.o")),
"collapse 中の個別ファイルは入らない: {set:?}"
);
assert!(
!set.contains(&canon.join("src/main.rs")),
"追跡ファイルは無視でない"
);
assert!(
!set.contains(&canon.join(".gitignore")),
".gitignore 自身は無視でない"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn file_diff_untracked_is_all_added() {
let dir = unique_tmp("konoma_git_filediff_untracked");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
let f = dir.join("a.txt");
std::fs::write(&f, b"line1\nline2\n").unwrap();
let diff = file_diff(&dir, &f);
assert!(!diff.is_empty(), "未追跡ファイルの diff が空");
assert!(
diff.iter().all(|l| l.kind == DiffLineKind::Added),
"未追跡は全行 Added のはず: {diff:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn file_diff_modified_has_added_and_removed() {
let dir = unique_tmp("konoma_git_filediff_modified");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
let f = dir.join("a.txt");
std::fs::write(&f, b"alpha\nbeta\n").unwrap();
let out = std::process::Command::new("git")
.current_dir(&dir)
.args(["add", "-A"])
.output()
.unwrap();
assert!(out.status.success());
let out = std::process::Command::new("git")
.current_dir(&dir)
.args(["commit", "-m", "init"])
.output()
.unwrap();
assert!(out.status.success(), "commit 失敗");
std::fs::write(&f, b"alpha\ngamma\n").unwrap();
let diff = file_diff(&dir, &f);
assert!(
diff.iter().any(|l| l.kind == DiffLineKind::Added),
"Added 行が無い: {diff:?}"
);
assert!(
diff.iter().any(|l| l.kind == DiffLineKind::Removed),
"Removed 行が無い: {diff:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(all(feature = "git", target_os = "macos"))]
#[test]
fn file_diff_finds_changes_for_an_nfd_named_file() {
let dir = unique_tmp("konoma_git_filediff_nfd");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
let canon = dir.canonicalize().unwrap();
let nfd_path = canon.join("\u{304B}\u{3099}_nfd.txt");
std::fs::write(&nfd_path, b"alpha\nbeta\n").unwrap();
stage(&dir, &nfd_path).unwrap();
commit(&dir, "init").unwrap();
std::fs::write(&nfd_path, b"alpha\ngamma\n").unwrap();
let diff = file_diff(&dir, &nfd_path);
assert!(
diff.iter().any(|l| l.kind == DiffLineKind::Added),
"Added 行が無い(NFD パスの diff が空): {diff:?}"
);
assert!(
diff.iter().any(|l| l.kind == DiffLineKind::Removed),
"Removed 行が無い(NFD パスの diff が空): {diff:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn changed_files_lists_staged_flag() {
let dir = unique_tmp("konoma_git_changed_files");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
std::fs::write(dir.join("a.txt"), b"hi\n").unwrap();
let before = changed_files(&dir);
assert_eq!(before.len(), 1);
assert!(!before[0].staged);
let out = std::process::Command::new("git")
.current_dir(&dir)
.args(["add", "a.txt"])
.output()
.unwrap();
assert!(out.status.success());
let after = changed_files(&dir);
assert_eq!(after.len(), 1);
assert!(after[0].staged, "add 後は staged=true のはず");
assert!(after[0].path.ends_with("a.txt"));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn changed_files_is_sorted_by_path_across_tracked_and_untracked() {
let dir = unique_tmp("konoma_git_changed_files_sorted");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
for name in ["mango.txt", "delta.txt"] {
std::fs::write(dir.join(name), b"one\n").unwrap();
}
stage_all(&dir).unwrap();
commit(&dir, "init").unwrap();
std::fs::write(dir.join("mango.txt"), b"two\n").unwrap();
std::fs::write(dir.join("delta.txt"), b"two\n").unwrap();
for name in ["zulu.txt", "kappa.txt", "alpha.txt"] {
std::fs::write(dir.join(name), b"new\n").unwrap();
}
let names: Vec<String> = changed_files(&dir)
.iter()
.map(|e| e.path.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(
names,
vec![
"alpha.txt",
"delta.txt",
"kappa.txt",
"mango.txt",
"zulu.txt"
],
"changed_files must be sorted by path, not grouped by tracked/untracked (git's own \
report order here is delta, mango, alpha, kappa, zulu): {names:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn stage_commit_then_log_and_commit_diff() {
let dir = unique_tmp("konoma_git_stage_commit_log");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
let f = dir.join("a.txt");
std::fs::write(&f, b"hello\n").unwrap();
stage(&dir, &f).unwrap();
commit(&dir, "first commit").unwrap();
let entries = log(&dir, 10);
assert_eq!(entries.len(), 1, "log は1件のはず");
assert_eq!(entries[0].summary, "first commit");
assert_eq!(entries[0].short.len(), 7);
let cd = commit_diff(&dir, &entries[0].id);
assert!(!cd.is_empty(), "commit_diff が空");
assert!(
cd.iter().any(|l| l.kind == DiffLineKind::Added),
"Added 行が無い: {cd:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn unstage_and_discard_work() {
let dir = unique_tmp("konoma_git_unstage_discard");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
let f = dir.join("a.txt");
std::fs::write(&f, b"v1\n").unwrap();
stage(&dir, &f).unwrap();
commit(&dir, "init").unwrap();
std::fs::write(&f, b"v2\n").unwrap();
stage(&dir, &f).unwrap();
assert!(changed_files(&dir).iter().any(|e| e.staged));
unstage(&dir, &f).unwrap();
assert!(
!changed_files(&dir).iter().any(|e| e.staged),
"unstage 後は staged が無いはず"
);
discard(&dir, &f).unwrap();
assert!(changed_files(&dir).is_empty(), "discard 後はクリーンのはず");
assert_eq!(std::fs::read_to_string(&f).unwrap(), "v1\n");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn lay_out_lanes_linear_dag_is_bounded() {
use std::time::{Duration, Instant};
let n = 1000usize;
let commits: Vec<DagCommit> = (0..n)
.map(|i| DagCommit {
kind: None,
id: format!("c{i}"),
parents: if i + 1 < n {
vec![format!("c{}", i + 1)]
} else {
Vec::new()
},
short: format!("c{i}"),
subject: format!("subject {i}"),
author: "a".into(),
date: "d".into(),
refs: String::new(),
})
.collect();
let t = Instant::now();
let rows = lay_out_lanes(&commits, None, None, crate::vcs::VcsKind::Git);
let dt = t.elapsed();
assert_eq!(
rows.iter().filter(|r| r.commit.is_some()).count(),
n,
"全コミット行が出る"
);
assert!(
dt < Duration::from_secs(2),
"1000 コミットのレーン割当が遅すぎる(回帰?): {dt:?}"
);
}
#[cfg(feature = "git")]
#[test]
fn lay_out_lanes_jj_uses_jjs_glyphs_not_gits_for_a_fork_and_merge() {
let dc = |id: &str, parents: &[&str]| DagCommit {
kind: None,
id: id.into(),
parents: parents.iter().map(|s| s.to_string()).collect(),
short: id.into(),
subject: format!("{id} subj"),
author: "a".into(),
date: "d".into(),
refs: String::new(),
};
let commits = vec![
dc("M", &["B", "F"]),
dc("B", &["R"]),
dc("F", &["R"]),
dc("R", &[]),
];
let rows = lay_out_lanes(&commits, None, None, crate::vcs::VcsKind::Jj);
let joined: Vec<String> = rows
.iter()
.map(|r| {
r.graph
.iter()
.map(|(s, _)| s.as_str())
.collect::<String>()
.trim_end()
.to_string()
})
.collect();
assert_eq!(
joined,
vec!["○", "├─┐", "○ │", "│ ○", "├─┘", "○"],
"jj 経路のグラフが期待と不一致(git の字が混ざっていないか): {joined:?}"
);
let all: String = rows
.iter()
.flat_map(|r| r.graph.iter().map(|(s, _)| s.clone()))
.collect();
assert!(
!all.contains('●') && !all.contains('◆'),
"jj 経路に git 専用の字(●/◆)が混ざっている: {all}"
);
assert_eq!(
all.matches('○').count(),
4,
"jj の丸ノード○は4個(全コミット分)"
);
}
#[cfg(feature = "git")]
#[test]
fn lay_out_lanes_jj_renders_working_copy_immutable_and_conflict_kinds() {
let dc = |id: &str, parents: &[&str], kind: Option<NodeKind>| DagCommit {
kind,
id: id.into(),
parents: parents.iter().map(|s| s.to_string()).collect(),
short: id.into(),
subject: id.into(),
author: "a".into(),
date: "d".into(),
refs: String::new(),
};
let commits = vec![
dc("W", &["I"], Some(NodeKind::WorkingCopy)),
dc("I", &["X"], Some(NodeKind::Immutable)),
dc("X", &["R"], Some(NodeKind::Conflict)),
dc("R", &[], None), ];
let rows = lay_out_lanes(&commits, None, None, crate::vcs::VcsKind::Jj);
let glyph_of = |id: &str| -> (Option<NodeKind>, String) {
let r = rows
.iter()
.find(|r| r.commit.as_deref() == Some(id))
.unwrap_or_else(|| panic!("commit {id} の行が無い: {rows:?}"));
let cell = r
.node_col
.and_then(|c| r.graph.get(c))
.map(|(s, _)| s.clone())
.unwrap_or_default();
(r.node, cell)
};
assert_eq!(
glyph_of("W"),
(Some(NodeKind::WorkingCopy), "@".to_string()),
"jj の作業コピー行は @"
);
assert_eq!(
glyph_of("I"),
(Some(NodeKind::Immutable), "◆".to_string()),
"jj の不変コミット行は ◆"
);
assert_eq!(
glyph_of("X"),
(Some(NodeKind::Conflict), "×".to_string()),
"jj の衝突コミット行は ×"
);
assert_eq!(
glyph_of("R"),
(Some(NodeKind::Normal), "○".to_string()),
"kind 未指定は構造(親1個)から Normal(○)と推定"
);
}
#[cfg(feature = "git")]
fn assert_node_col_points_at_its_own_glyph(rows: &[GraphRow], vcs: crate::vcs::VcsKind) {
for r in rows {
let (Some(kind), Some(col)) = (r.node, r.node_col) else {
continue; };
let cell = r
.graph
.get(col)
.unwrap_or_else(|| panic!("node_col={col} が graph の範囲外: {r:?}"));
assert_eq!(
cell.0,
crate::ui::icons::node_glyph(kind, vcs).to_string(),
"node_col の位置の字が node_glyph(kind, vcs) と不一致: row={r:?}"
);
}
}
#[cfg(feature = "git")]
#[test]
fn lay_out_lanes_node_col_matches_its_glyph_git() {
let dc = |id: &str, parents: &[&str]| DagCommit {
kind: None,
id: id.into(),
parents: parents.iter().map(|s| s.to_string()).collect(),
short: id.into(),
subject: id.into(),
author: "a".into(),
date: "d".into(),
refs: String::new(),
};
let commits = vec![
dc("A1", &["A2"]),
dc("A2", &["R"]),
dc("B1", &["B2"]),
dc("B2", &["R"]),
dc("R", &[]),
];
let rows = lay_out_lanes(&commits, Some("B1"), None, crate::vcs::VcsKind::Git);
assert_eq!(rows.iter().filter(|r| r.commit.is_some()).count(), 5);
assert_node_col_points_at_its_own_glyph(&rows, crate::vcs::VcsKind::Git);
}
#[cfg(feature = "git")]
#[test]
fn lay_out_lanes_node_col_matches_its_glyph_jj() {
let dc = |id: &str, parents: &[&str], kind: Option<NodeKind>| DagCommit {
kind,
id: id.into(),
parents: parents.iter().map(|s| s.to_string()).collect(),
short: id.into(),
subject: id.into(),
author: "a".into(),
date: "d".into(),
refs: String::new(),
};
let commits = vec![
dc("W", &["H"], Some(NodeKind::WorkingCopy)),
dc("H", &["A", "B"], None), dc("A", &["R"], Some(NodeKind::Immutable)),
dc("B", &["R"], Some(NodeKind::Conflict)),
dc("R", &[], None), ];
let rows = lay_out_lanes(&commits, None, None, crate::vcs::VcsKind::Jj);
assert_eq!(
rows.iter().filter(|r| r.commit.is_some()).count(),
5,
"全コミットが行として残る"
);
assert_node_col_points_at_its_own_glyph(&rows, crate::vcs::VcsKind::Jj);
}
}