use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use crate::git::FileStatus;
const IGNORE_WORKING_COPY: &str = "--ignore-working-copy";
const CONFIRM_CAP: usize = 256;
fn args<'a>(rest: &[&'a str]) -> Vec<&'a str> {
let mut v = Vec::with_capacity(rest.len() + 1);
v.extend_from_slice(rest);
v.push(IGNORE_WORKING_COPY);
v
}
fn run(cwd: &Path, rest: &[&str]) -> Option<String> {
let out = std::process::Command::new("jj")
.current_dir(cwd)
.args(args(rest))
.stdin(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn snapshot(root: &Path) -> bool {
let Some(ws) = workspace_root(root) else {
return false;
};
std::process::Command::new("jj")
.current_dir(&ws)
.args(["status"]) .stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn workspace_root(root: &Path) -> Option<PathBuf> {
let mut cur = Some(root);
while let Some(dir) = cur {
if dir.join(".jj").exists() {
return Some(dir.to_path_buf());
}
cur = dir.parent();
}
None
}
pub fn available() -> bool {
static OK: OnceLock<bool> = OnceLock::new();
*OK.get_or_init(|| {
std::process::Command::new("jj")
.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)
})
}
#[derive(Debug, Clone)]
pub struct Meta {
pub change: String,
pub summary: String,
pub snapshot_epoch: i64,
pub parent: Option<String>,
}
const META_TEMPLATE: &str = concat!(
r#"change_id.shortest(8) ++ "\t" ++ committer.timestamp().format("%s")"#,
r#" ++ "\t" ++ parents.map(|p| p.commit_id()).join(" ")"#,
r#" ++ "\t" ++ description.first_line() ++ "\n""#,
);
pub fn meta(ws: &Path) -> Option<Meta> {
let line = run(ws, &["log", "-r", "@", "--no-graph", "-T", META_TEMPLATE])?;
let line = line.lines().next()?;
parse_meta_line(line)
}
fn parse_meta_line(line: &str) -> Option<Meta> {
let mut f = line.splitn(4, '\t');
let change = f.next()?.to_string();
let snapshot_epoch = f.next()?.trim().parse::<i64>().ok()?;
let parent = f.next()?.split_whitespace().next().map(str::to_string);
let summary = f.next().unwrap_or("").to_string();
if change.is_empty() {
return None;
}
Some(Meta {
change,
summary,
snapshot_epoch,
parent,
})
}
pub fn branch(root: &Path) -> Option<String> {
let ws = workspace_root(root)?;
let m = meta(&ws)?;
Some(if m.summary.is_empty() {
format!("@ {}", m.change)
} else {
format!("@ {} {}", m.change, m.summary)
})
}
struct SummaryEntry {
status: FileStatus,
path: String,
from: Option<String>,
}
fn parse_diff_summary(out: &str) -> Vec<SummaryEntry> {
let mut v = Vec::new();
for line in out.lines() {
let Some((mark, rest)) = line.split_once(' ') else {
continue;
};
if mark == "R" {
if let Some((from, to)) = split_rename(rest) {
v.push(SummaryEntry {
status: FileStatus::Renamed,
path: to,
from: Some(from),
});
}
continue;
}
let status = match mark {
"A" => FileStatus::Added,
"D" => FileStatus::Deleted,
"C" => FileStatus::Renamed, _ => FileStatus::Modified,
};
v.push(SummaryEntry {
status,
path: rest.to_string(),
from: None,
});
}
v
}
fn split_rename(spec: &str) -> Option<(String, String)> {
let open = spec.find('{')?;
let close = open + spec[open..].find('}')?;
let (from, to) = spec[open + 1..close].split_once(" => ")?;
let prefix = &spec[..open];
let suffix = &spec[close + 1..];
Some((
join_rename_side(prefix, from, suffix),
join_rename_side(prefix, to, suffix),
))
}
fn join_rename_side(prefix: &str, part: &str, suffix: &str) -> String {
let joined = format!("{prefix}{part}{suffix}");
let collapsed = joined.replace("//", "/");
collapsed.trim_start_matches('/').to_string()
}
const DIFF_TEMPLATE: &str = concat!(
r#"self.status_char() ++ "\t" ++ self.source().path() ++ "\t""#,
r#" ++ self.target().path() ++ "\n""#,
);
fn diff_template_available(ws: &Path) -> bool {
let cache = TEMPLATE_OK.get_or_init(|| Mutex::new(HashMap::new()));
if let Ok(map) = cache.lock() {
if let Some(&ok) = map.get(ws) {
return ok;
}
}
let ok = run(ws, &["diff", "-r", "@", "-T", DIFF_TEMPLATE]).is_some();
if let Ok(mut map) = cache.lock() {
map.insert(ws.to_path_buf(), ok);
}
ok
}
static TEMPLATE_OK: OnceLock<Mutex<HashMap<PathBuf, bool>>> = OnceLock::new();
fn diff_entries(ws: &Path, revset: Option<&str>) -> Vec<SummaryEntry> {
let mut call = vec!["diff"];
if let Some(r) = revset {
call.push("-r");
call.push(r);
}
if diff_template_available(ws) {
call.push("-T");
call.push(DIFF_TEMPLATE);
return run(ws, &call)
.map(|out| parse_diff_template(&out))
.unwrap_or_default();
}
call.push("--summary");
run(ws, &call)
.map(|out| parse_diff_summary(&out))
.unwrap_or_default()
}
fn parse_diff_template(out: &str) -> Vec<SummaryEntry> {
let mut v = Vec::new();
for line in out.lines() {
let mut f = line.splitn(3, '\t');
let Some(mark) = f.next() else { continue };
let Some(source) = f.next() else { continue };
let Some(target) = f.next() else { continue };
let status = match mark {
"A" => FileStatus::Added,
"D" => FileStatus::Deleted,
"C" | "R" => FileStatus::Renamed, _ => FileStatus::Modified,
};
let from = (source != target).then(|| source.to_string());
v.push(SummaryEntry {
status,
path: target.to_string(),
from,
});
}
v
}
fn known_changes(ws: &Path) -> HashMap<String, FileStatus> {
diff_entries(ws, None)
.into_iter()
.map(|e| (e.path, e.status))
.collect()
}
fn tracked(ws: &Path, parent: Option<&str>) -> HashSet<String> {
let Some(parent) = parent else {
return HashSet::new();
};
run(ws, &["file", "list", "-r", parent])
.map(|o| parse_file_list(&o))
.unwrap_or_default()
}
fn parse_file_list(out: &str) -> HashSet<String> {
out.lines().map(|l| l.to_string()).collect()
}
fn differs_from_parent(ws: &Path, rel: &str, disk: &Path, parent: Option<&str>) -> bool {
let Ok(now) = std::fs::read(disk) else {
return true; };
match parent_bytes(ws, rel, parent) {
Some(before) => before != now,
None => true,
}
}
fn scan(ws: &Path) -> Vec<(PathBuf, FileStatus)> {
let mut out = Vec::new();
let Some(m) = meta(ws) else {
return out; };
let known = known_changes(ws);
let tracked = tracked(ws, m.parent.as_deref());
let mut seen: HashSet<String> = HashSet::new();
let mut confirmed = 0usize;
for entry in walk(ws) {
let Some(ft) = entry.file_type() else {
continue; };
let is_symlink = ft.is_symlink();
if !ft.is_file() && !is_symlink {
continue;
}
let Some(rel) = entry.path().strip_prefix(ws).ok().and_then(|p| p.to_str()) else {
continue;
};
let rel = rel.to_string();
seen.insert(rel.clone());
let st = if let Some(&st) = known.get(&rel) {
st
} else if !newer_than(entry.path(), m.snapshot_epoch) {
continue; } else if !tracked.contains(&rel) {
FileStatus::Added
} else if is_symlink {
FileStatus::Modified
} else if confirmed < CONFIRM_CAP {
confirmed += 1;
if differs_from_parent(ws, &rel, entry.path(), m.parent.as_deref()) {
FileStatus::Modified
} else {
continue;
}
} else {
FileStatus::Modified
};
out.push((crate::git::normalize_status_path(entry.into_path()), st));
}
for rel in tracked.difference(&seen) {
out.push((ws.join(rel), FileStatus::Deleted));
}
out
}
pub fn statuses(root: &Path) -> HashMap<PathBuf, FileStatus> {
let mut map = HashMap::new();
let Some(ws) = workspace_root(root) else {
return map;
};
for (abs, st) in scan(&ws) {
crate::git::rollup(&mut map, &ws, &abs, st);
}
map
}
pub fn changed_files(root: &Path) -> Vec<crate::git::ChangeEntry> {
let Some(ws) = workspace_root(root) else {
return Vec::new();
};
let mut out: Vec<crate::git::ChangeEntry> = scan(&ws)
.into_iter()
.map(|(path, status)| crate::git::ChangeEntry {
path,
status,
staged: false,
})
.collect();
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
pub fn file_diff(root: &Path, file: &Path) -> Vec<crate::git::DiffLine> {
let mut out = Vec::new();
let Some(ws) = workspace_root(root) else {
return out;
};
if std::fs::symlink_metadata(file).is_ok_and(|m| m.file_type().is_symlink()) {
return out; }
let abs = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
let ws_abs = ws.canonicalize().unwrap_or_else(|_| ws.clone());
let Ok(rel) = abs.strip_prefix(&ws_abs) else {
return out; };
let Some(rel_str) = rel.to_str() else {
return out;
};
let Some(m) = meta(&ws) else {
return out; };
let before = parent_bytes(&ws, rel_str, m.parent.as_deref());
let after = std::fs::read(&abs).ok();
crate::git::push_file_diff(&mut out, rel, before.as_deref(), after.as_deref(), false);
out
}
fn parent_bytes(ws: &Path, rel: &str, parent: Option<&str>) -> Option<Vec<u8>> {
revision_bytes(ws, parent?, rel)
}
pub fn ignored(root: &Path) -> HashSet<PathBuf> {
let mut set = HashSet::new();
let Some(ws) = workspace_root(root) else {
return set;
};
let visible: HashSet<PathBuf> = walk(&ws).map(ignore::DirEntry::into_path).collect();
let mut stack = vec![ws];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
if name == ".jj" || name == ".git" {
continue; }
if !visible.contains(&path) {
set.insert(path); continue;
}
if entry.file_type().is_ok_and(|t| t.is_dir()) {
stack.push(path);
}
}
}
set
}
fn walk(ws: &Path) -> impl Iterator<Item = ignore::DirEntry> {
ignore::WalkBuilder::new(ws)
.hidden(false) .require_git(false)
.filter_entry(|e| e.file_name() != ".jj" && e.file_name() != ".git")
.build()
.filter_map(Result::ok)
}
fn newer_than(path: &Path, epoch: i64) -> bool {
let Ok(meta) = std::fs::symlink_metadata(path) else {
return true;
};
let Ok(mtime) = meta.modified() else {
return true;
};
match mtime.duration_since(std::time::UNIX_EPOCH) {
Ok(d) => d.as_secs() as i64 >= epoch,
Err(_) => true,
}
}
const DAG_TEMPLATE: &str = concat!(
r#"commit_id ++ "\t" ++ parents.map(|p| p.commit_id()).join(" ") ++ "\t""#,
r#" ++ change_id.shortest(8) ++ "\t" ++ author.name() ++ "\t""#,
r#" ++ committer.timestamp().format("%Y-%m-%d") ++ "\t""#,
r#" ++ committer.timestamp().format("%s") ++ "\t" ++ working_copies ++ "\t""#,
r#" ++ bookmarks.join(",") ++ "\t" ++ if(conflict,"c","") ++ if(immutable,"i","")"#,
r#" ++ if(current_working_copy,"w","") ++ "\t" ++ description.first_line() ++ "\n""#,
);
const DAG_FIELDS: usize = 10;
struct Row {
commit: String,
parents: Vec<String>,
change: String,
subject: String,
author: String,
date: String,
epoch: i64,
workspaces: String,
bookmarks: String,
conflict: bool,
immutable: bool,
here: bool,
}
fn rows(ws: &Path, revset: Option<&str>, max: usize) -> Vec<Row> {
let mut call = vec!["log", "--no-graph", "-T", DAG_TEMPLATE];
if let Some(r) = revset {
call.push("-r");
call.push(r);
}
let Some(out) = run(ws, &call) else {
return Vec::new();
};
out.lines().take(max).filter_map(parse_dag_line).collect()
}
fn parse_dag_line(line: &str) -> Option<Row> {
let f: Vec<&str> = line.splitn(DAG_FIELDS, '\t').collect();
if f.len() < DAG_FIELDS {
return None;
}
Some(Row {
commit: f[0].to_string(),
parents: f[1].split_whitespace().map(str::to_string).collect(),
change: f[2].to_string(),
author: f[3].to_string(),
date: f[4].to_string(),
epoch: f[5].trim().parse().unwrap_or(0),
workspaces: f[6].to_string(),
bookmarks: f[7].to_string(),
conflict: f[8].contains('c'),
immutable: f[8].contains('i'),
here: f[8].contains('w'),
subject: f[9].to_string(),
})
}
fn decorations(r: &Row) -> String {
[
r.workspaces.as_str(),
r.bookmarks.as_str(),
if r.conflict { "conflict" } else { "" },
]
.iter()
.filter(|s| !s.is_empty())
.copied()
.collect::<Vec<_>>()
.join(", ")
}
fn node_kind(r: &Row) -> crate::git::NodeKind {
use crate::git::NodeKind as K;
if r.here {
K::WorkingCopy
} else if r.conflict {
K::Conflict
} else if r.immutable {
K::Immutable
} else if r.parents.len() >= 2 {
K::Merge
} else {
K::Normal
}
}
pub fn log(root: &Path, max: usize) -> Vec<crate::git::CommitInfo> {
let Some(ws) = workspace_root(root) else {
return Vec::new();
};
rows(&ws, None, max)
.into_iter()
.map(|r| crate::git::CommitInfo {
id: r.commit,
short: r.change,
summary: r.subject,
author: r.author,
time_epoch: r.epoch,
})
.collect()
}
pub fn graph(root: &Path, revset: Option<&str>, max: usize) -> Vec<crate::git::GraphRow> {
let Some(ws) = workspace_root(root) else {
return Vec::new();
};
let commits: Vec<crate::git::DagCommit> = rows(&ws, revset, max)
.into_iter()
.map(|r| crate::git::DagCommit {
kind: Some(node_kind(&r)),
refs: decorations(&r),
id: r.commit,
short: r.change,
subject: if r.immutable && r.parents.is_empty() && r.subject.is_empty() {
"root()".to_string() } else {
r.subject
},
author: if r.epoch == 0 {
String::new()
} else {
r.author
},
date: if r.epoch == 0 { String::new() } else { r.date },
parents: r.parents,
})
.collect();
crate::git::lay_out_lanes(&commits, None, None, crate::vcs::VcsKind::Jj)
}
pub fn bookmarks(root: &Path) -> Vec<crate::git::BranchInfo> {
let Some(ws) = workspace_root(root) else {
return Vec::new();
};
let Some(out) = run(&ws, &["bookmark", "list", "-T", r#"name ++ "\n""#]) else {
return Vec::new();
};
parse_bookmark_list(&out)
}
fn parse_bookmark_list(out: &str) -> Vec<crate::git::BranchInfo> {
let mut v: Vec<crate::git::BranchInfo> = out
.lines()
.filter(|l| !l.is_empty())
.map(|name| crate::git::BranchInfo {
name: name.to_string(),
is_current: false,
})
.collect();
v.sort_by(|a, b| a.name.cmp(&b.name));
v.dedup_by(|a, b| a.name == b.name);
v
}
pub fn bookmark_tip(root: &Path, name: &str) -> Option<String> {
let ws = workspace_root(root)?;
let out = run(&ws, &["log", "-r", name, "--no-graph", "-T", "commit_id"])?;
let id = out.lines().next()?.trim().to_string();
(!id.is_empty()).then_some(id)
}
pub fn commit_meta(root: &Path, id: &str) -> Option<crate::git::CommitMeta> {
let ws = workspace_root(root)?;
const T: &str = concat!(
r#"commit_id ++ "\t" ++ change_id.shortest(8) ++ "\t" ++ author.name() ++ "\t""#,
r#" ++ committer.timestamp().format("%Y-%m-%d %H:%M") ++ "\t" ++ description"#,
);
let out = run(&ws, &["log", "-r", id, "--no-graph", "-T", T])?;
parse_commit_meta(&out)
}
fn parse_commit_meta(out: &str) -> Option<crate::git::CommitMeta> {
let mut f = out.splitn(5, '\t');
let commit = f.next()?.to_string();
let change = f.next()?.to_string();
let author = f.next()?.to_string();
let date = f.next()?.to_string();
let message = f.next().unwrap_or("").trim_end().to_string();
if commit.is_empty() {
return None;
}
Some(crate::git::CommitMeta {
id: commit,
short: change,
author,
date,
message,
})
}
const PARENT_ONLY_TEMPLATE: &str = r#"parents.map(|p| p.commit_id()).join(" ") ++ "\n""#;
fn first_parent(ws: &Path, id: &str) -> Option<String> {
let out = run(
ws,
&["log", "-r", id, "--no-graph", "-T", PARENT_ONLY_TEMPLATE],
)?;
out.lines()
.next()?
.split_whitespace()
.next()
.map(str::to_string)
}
pub fn commit_diff(root: &Path, id: &str) -> Vec<crate::git::DiffLine> {
let mut out = Vec::new();
let Some(ws) = workspace_root(root) else {
return out;
};
let parent = first_parent(&ws, id);
for entry in diff_entries(&ws, Some(id)) {
let before_rel = entry.from.as_deref().unwrap_or(&entry.path);
let before = parent
.as_deref()
.and_then(|p| revision_bytes(&ws, p, before_rel));
let after = revision_bytes(&ws, id, &entry.path);
crate::git::push_file_diff(
&mut out,
Path::new(&entry.path),
before.as_deref(),
after.as_deref(),
true,
);
}
out
}
fn fileset_literal(rel: &str) -> String {
let escaped = rel.replace('\\', "\\\\").replace('"', "\\\"");
format!("file:\"{escaped}\"")
}
fn revision_bytes(ws: &Path, rev: &str, rel: &str) -> Option<Vec<u8>> {
let literal = fileset_literal(rel);
let out = std::process::Command::new("jj")
.current_dir(ws)
.args(args(&["file", "show", "-r", rev, &literal]))
.stdin(std::process::Stdio::null())
.output()
.ok()?;
out.status.success().then_some(out.stdout)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vcs::Vcs;
#[test]
fn every_invocation_refuses_to_snapshot() {
for call in [
vec!["log", "-r", "@"],
vec!["diff", "--summary"],
vec!["file", "list", "-r", "@-"],
vec!["file", "show", "-r", "@-", "a.txt"],
] {
assert!(
args(&call).contains(&IGNORE_WORKING_COPY),
"a jj call would have snapshotted the working copy: {call:?}"
);
}
}
#[test]
fn the_flag_is_appended_not_inserted() {
let a = args(&["log", "-r", "@"]);
assert_eq!(a.first(), Some(&"log"));
assert_eq!(a.last(), Some(&IGNORE_WORKING_COPY));
}
#[test]
fn only_the_named_exceptions_run_jj_without_the_flag() {
let src = include_str!("jj.rs");
let code = &src[..src.find("#[cfg(test)]").unwrap_or(src.len())];
let mut unflagged = Vec::new();
for (i, _) in code.match_indices("Command::new(\"jj\")") {
let tail = &code[i..(i + 400).min(code.len())];
if tail.contains(".args(args(") {
continue;
}
let head = &code[..i];
let name = head
.rfind("pub fn ")
.map(|j| head[j + "pub fn ".len()..].split('(').next().unwrap_or("?"))
.unwrap_or("?");
unflagged.push(name.to_string());
}
unflagged.sort();
assert_eq!(
unflagged,
vec!["available", "snapshot"],
"a jj call would snapshot the working copy without being asked"
);
}
#[test]
fn no_marker_means_no_workspace() {
assert!(workspace_root(Path::new("/")).is_none());
}
#[test]
fn command_new_count_is_pinned() {
let src = include_str!("jj.rs");
let code = &src[..src.find("#[cfg(test)]").unwrap_or(src.len())];
let count = code.matches("Command::new(").count();
assert_eq!(
count, 4,
"the number of `Command::new(` calls in src/vcs/jj.rs changed — if you added a new \
jj invocation, route it through args() so it carries --ignore-working-copy (unless \
it deliberately does not write, like `snapshot`), then update this expected count \
to match"
);
}
#[test]
fn fileset_literal_never_hands_jj_a_bare_path() {
for rel in [
"-dash.txt",
"has space.txt",
"quote\".txt",
"back\\slash.txt",
"plain.txt",
] {
let lit = fileset_literal(rel);
assert!(
lit.starts_with("file:\""),
"a raw path must never reach jj's argument parser unescaped: {lit:?}"
);
}
}
#[test]
fn fileset_literal_escapes_quotes_and_backslashes() {
assert_eq!(fileset_literal("-dash.txt"), "file:\"-dash.txt\"");
assert_eq!(fileset_literal("has space.txt"), "file:\"has space.txt\"");
assert_eq!(fileset_literal("quote\".txt"), "file:\"quote\\\".txt\"");
assert_eq!(
fileset_literal("back\\slash.txt"),
"file:\"back\\\\slash.txt\""
);
}
#[test]
fn diff_summary_reads_plain_marks() {
let entries = parse_diff_summary("A added.txt\nM modified.txt\nD deleted.txt\n");
let got: Vec<(FileStatus, &str, Option<&str>)> = entries
.iter()
.map(|e| (e.status, e.path.as_str(), e.from.as_deref()))
.collect();
assert_eq!(
got,
vec![
(FileStatus::Added, "added.txt", None),
(FileStatus::Modified, "modified.txt", None),
(FileStatus::Deleted, "deleted.txt", None),
]
);
}
#[test]
fn diff_summary_keeps_a_space_in_the_path() {
let entries = parse_diff_summary("M has space.txt\n");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, "has space.txt");
}
#[test]
fn diff_summary_parses_every_compressed_rename_shape() {
let entries = parse_diff_summary(
"R {orig.txt => renamed.txt}\n\
R {src => lib}/foo.txt\n\
R {lib/foo.txt => foo.txt}\n\
R {foo.txt => lib/foo.txt}\n",
);
let got: Vec<(&str, &str)> = entries
.iter()
.map(|e| (e.from.as_deref().unwrap_or(""), e.path.as_str()))
.collect();
assert_eq!(
got,
vec![
("orig.txt", "renamed.txt"),
("src/foo.txt", "lib/foo.txt"),
("lib/foo.txt", "foo.txt"),
("foo.txt", "lib/foo.txt"),
]
);
assert!(entries.iter().all(|e| e.status == FileStatus::Renamed));
}
#[test]
fn diff_summary_rejoins_a_rename_whose_side_is_empty() {
let entries = parse_diff_summary("R a/{b => }/c.txt\n");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].from.as_deref(), Some("a/b/c.txt"));
assert_eq!(
entries[0].path, "a/c.txt",
"the destination must be the path the tree walk sees, not a//c.txt"
);
}
#[test]
fn diff_summary_does_not_misfire_on_a_filename_containing_arrow() {
let entries = parse_diff_summary("M a=>b.txt\n");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].status, FileStatus::Modified);
assert_eq!(entries[0].path, "a=>b.txt");
assert!(entries[0].from.is_none());
}
#[test]
fn diff_summary_of_empty_input_is_empty() {
assert!(parse_diff_summary("").is_empty());
}
#[test]
fn diff_summary_tolerates_a_trailing_newline() {
let entries = parse_diff_summary("A added.txt\n\n");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, "added.txt");
}
#[test]
fn diff_template_reads_a_rename_whose_name_contains_curly_braces() {
let entries = parse_diff_template("R\tweird{a}.txt\tweird{b}.txt\n");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].status, FileStatus::Renamed);
assert_eq!(entries[0].from.as_deref(), Some("weird{a}.txt"));
assert_eq!(entries[0].path, "weird{b}.txt");
}
#[test]
fn diff_template_reads_a_rename_whose_name_contains_the_literal_arrow() {
let entries = parse_diff_template("R\tarrow => src.txt\tplaindest.txt\n");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].status, FileStatus::Renamed);
assert_eq!(entries[0].from.as_deref(), Some("arrow => src.txt"));
assert_eq!(entries[0].path, "plaindest.txt");
}
#[test]
fn diff_template_reads_add_modify_delete_with_no_from() {
let entries = parse_diff_template(
"A\tadded.txt\tadded.txt\n\
M\tmodified.txt\tmodified.txt\n\
D\tdeleted.txt\tdeleted.txt\n",
);
let got: Vec<(FileStatus, &str, Option<&str>)> = entries
.iter()
.map(|e| (e.status, e.path.as_str(), e.from.as_deref()))
.collect();
assert_eq!(
got,
vec![
(FileStatus::Added, "added.txt", None),
(FileStatus::Modified, "modified.txt", None),
(FileStatus::Deleted, "deleted.txt", None),
]
);
}
#[test]
fn diff_template_reads_a_copy() {
let entries = parse_diff_template("C\tsrc.txt\tcopy.txt\n");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].status, FileStatus::Renamed); assert_eq!(entries[0].from.as_deref(), Some("src.txt"));
assert_eq!(entries[0].path, "copy.txt");
}
#[test]
fn diff_template_drops_a_line_with_too_few_fields() {
assert!(parse_diff_template("A\tonly-one-field\n").is_empty());
assert!(parse_diff_template("just-one-field-no-tab\n").is_empty());
}
#[test]
fn diff_template_of_empty_input_is_empty() {
assert!(parse_diff_template("").is_empty());
}
#[test]
fn diff_template_tolerates_a_trailing_newline() {
let entries = parse_diff_template("A\tadded.txt\tadded.txt\n\n");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, "added.txt");
}
#[test]
fn diff_template_keeps_a_tab_embedded_in_the_target_path() {
let entries = parse_diff_template("R\tplain.txt\tweird\tname.txt\n");
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0].path, "weird\tname.txt",
"the target must absorb everything after the second tab, unsplit"
);
assert_eq!(entries[0].from.as_deref(), Some("plain.txt"));
}
fn dag_line(subject: &str, flags: &str) -> String {
format!("commit1\tparent1\tchange1\tauthor\t2026-08-19\t1755600000\t\t\t{flags}\t{subject}")
}
#[test]
fn dag_line_parses_a_normal_row() {
let row = parse_dag_line(&dag_line("subject", "w")).expect("a well-formed row parses");
assert_eq!(row.commit, "commit1");
assert_eq!(row.parents, vec!["parent1"]);
assert_eq!(row.change, "change1");
assert_eq!(row.author, "author");
assert_eq!(row.date, "2026-08-19");
assert_eq!(row.epoch, 1_755_600_000);
assert_eq!(row.subject, "subject");
assert!(row.here);
assert!(!row.conflict);
assert!(!row.immutable);
}
#[test]
fn dag_line_keeps_a_tab_inside_the_description() {
let line = dag_line("first\tsecond", "w");
let row = parse_dag_line(&line).expect("a tab in the subject must not break the parse");
assert_eq!(row.subject, "first\tsecond");
assert_eq!(
row.author, "author",
"author must not shift: {}",
row.author
);
assert_eq!(row.epoch, 1_755_600_000, "epoch must not shift");
assert!(
row.here,
"the working-copy flag must still be read: {line:?}"
);
}
#[test]
fn dag_line_with_too_few_fields_is_dropped() {
assert!(parse_dag_line("commit1\tparent1\tchange1").is_none());
}
#[test]
fn dag_line_with_non_numeric_epoch_falls_back_to_zero() {
let line = "commit1\tparent1\tchange1\tauthor\t2026-08-19\tnot-a-number\t\t\t\tsubject";
let row = parse_dag_line(line).expect("a bad epoch must not drop the whole row");
assert_eq!(row.epoch, 0);
}
fn meta_line(epoch: &str, parents: &str, summary: &str) -> String {
format!("change1\t{epoch}\t{parents}\t{summary}")
}
#[test]
fn meta_line_parses_normally() {
let m =
parse_meta_line(&meta_line("1755600000", "parent1", "subject")).expect("well-formed");
assert_eq!(m.change, "change1");
assert_eq!(m.snapshot_epoch, 1_755_600_000);
assert_eq!(m.summary, "subject");
assert_eq!(m.parent.as_deref(), Some("parent1"));
}
#[test]
fn meta_line_keeps_a_tab_inside_the_summary() {
let m = parse_meta_line(&meta_line("1755600000", "parent1", "first\tsecond"))
.expect("well-formed");
assert_eq!(m.summary, "first\tsecond");
}
#[test]
fn meta_line_with_empty_change_is_none() {
assert!(parse_meta_line("\t1755600000\tparent1\tsubject").is_none());
}
#[test]
fn meta_line_with_non_numeric_epoch_is_none() {
assert!(parse_meta_line("change1\tnot-a-number\tparent1\tsubject").is_none());
}
#[test]
fn meta_line_keeps_only_the_first_of_two_parents() {
let m = parse_meta_line(&meta_line("1755600000", "parentA parentB", "merge"))
.expect("well-formed");
assert_eq!(m.parent.as_deref(), Some("parentA"));
}
#[test]
fn meta_line_with_no_parents_reads_as_none() {
let m = parse_meta_line(&meta_line("1755600000", "", "root")).expect("well-formed");
assert_eq!(m.parent, None);
}
#[test]
fn bookmark_list_dedups_and_sorts() {
let v = parse_bookmark_list("main\nfeature\n\nmain\n");
let names: Vec<&str> = v.iter().map(|b| b.name.as_str()).collect();
assert_eq!(names, vec!["feature", "main"]);
assert!(v.iter().all(|b| !b.is_current));
}
#[test]
fn commit_meta_keeps_newlines_in_a_multi_line_description() {
let out = "abc123\tchange1\tsomeone\t2026-08-19 12:00\tfirst line\nsecond line\nthird";
let m = parse_commit_meta(out).expect("well-formed");
assert_eq!(m.id, "abc123");
assert_eq!(m.short, "change1");
assert_eq!(m.author, "someone");
assert_eq!(m.date, "2026-08-19 12:00");
assert_eq!(m.message, "first line\nsecond line\nthird");
}
#[test]
fn commit_meta_with_too_few_fields_is_none() {
assert!(parse_commit_meta("abc123\tchange1").is_none());
}
fn scratch_repo(name: &str) -> Option<PathBuf> {
if !available() {
return None;
}
let dir = std::env::temp_dir().join(format!("konoma_jj_{name}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).ok()?;
let jj = |args: &[&str]| {
std::process::Command::new("jj")
.current_dir(&dir)
.env("HOME", &dir)
.env("JJ_USER", "konoma test")
.env("JJ_EMAIL", "test@example.invalid")
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
if !jj(&["git", "init", "--no-colocate", "."]) {
return None;
}
std::fs::write(
dir.join("orig-for-rename.txt"),
b"line one\nline two\nline three\nline four\nline five\n",
)
.ok()?;
if !jj(&["commit", "-m", "genesis"]) {
return None;
}
std::fs::rename(dir.join("orig-for-rename.txt"), dir.join("renamed.txt")).ok()?;
std::fs::write(
dir.join("renamed.txt"),
b"line one\nline two\nline three\nline four\nline five extended\n",
)
.ok()?;
std::fs::write(dir.join("kept.txt"), b"one\n").ok()?;
std::fs::write(dir.join("changed.txt"), b"before\n").ok()?;
std::fs::write(dir.join("-dash-kept.txt"), b"dash kept\n").ok()?;
std::fs::write(dir.join("-dash.txt"), b"dash before\n").ok()?;
std::os::unix::fs::symlink("kept.txt", dir.join("link.txt")).ok()?;
std::thread::sleep(std::time::Duration::from_millis(1100));
if !jj(&["commit", "-m", "seed"]) {
return None;
}
std::fs::write(dir.join("changed.txt"), b"after\n").ok()?;
std::fs::write(dir.join("added.txt"), b"new\n").ok()?;
std::fs::write(dir.join("-dash.txt"), b"dash after\n").ok()?;
Some(dir)
}
fn scratch_repo_with_ambiguous_renames(name: &str) -> Option<PathBuf> {
if !available() {
return None;
}
let dir = std::env::temp_dir().join(format!("konoma_jj_{name}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).ok()?;
let jj = |args: &[&str]| {
std::process::Command::new("jj")
.current_dir(&dir)
.env("HOME", &dir)
.env("JJ_USER", "konoma test")
.env("JJ_EMAIL", "test@example.invalid")
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
if !jj(&["git", "init", "--no-colocate", "."]) {
return None;
}
std::fs::write(
dir.join("weird{a}.txt"),
b"line one\nline two\nline three\nline four\nline five\n",
)
.ok()?;
std::fs::write(
dir.join("arrow => src.txt"),
b"line one\nline two\nline three\nline four\nline five\n",
)
.ok()?;
if !jj(&["commit", "-m", "genesis"]) {
return None;
}
std::fs::rename(dir.join("weird{a}.txt"), dir.join("weird{b}.txt")).ok()?;
std::fs::write(
dir.join("weird{b}.txt"),
b"line one\nline two\nline three\nline four\nline five extended\n",
)
.ok()?;
std::fs::rename(dir.join("arrow => src.txt"), dir.join("plaindest.txt")).ok()?;
std::fs::write(
dir.join("plaindest.txt"),
b"line one\nline two\nline three\nline four\nline five extended\n",
)
.ok()?;
if !jj(&["commit", "-m", "seed"]) {
return None;
}
Some(dir)
}
#[test]
fn reports_changes_jj_has_not_snapshotted() {
let Some(dir) = scratch_repo("statuses") else {
return;
};
let st = statuses(&dir);
assert_eq!(
st.get(&dir.join("changed.txt")),
Some(&FileStatus::Modified),
"an edited file must be modified: {st:?}"
);
assert_eq!(
st.get(&dir.join("added.txt")),
Some(&FileStatus::Added),
"a new file must be added, not untracked — jj has no untracked state: {st:?}"
);
assert!(
!st.contains_key(&dir.join("kept.txt")),
"an untouched file must carry no marker: {st:?}"
);
let files = changed_files(&dir);
assert_eq!(files.len(), 3, "one entry per changed file: {files:?}");
assert!(
files.iter().all(|f| !f.staged),
"jj has no index, so nothing can be staged: {files:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn diffs_against_the_parent_commit() {
let Some(dir) = scratch_repo("diff") else {
return;
};
let lines = file_diff(&dir, &dir.join("changed.txt"));
let text: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
assert!(
text.contains(&"before"),
"the parent's line is missing: {text:?}"
);
assert!(
text.contains(&"after"),
"the working copy's line is missing: {text:?}"
);
let added = file_diff(&dir, &dir.join("added.txt"));
assert!(
added.iter().all(|l| l.old_no.is_none()),
"a file the parent does not have must read as all-added: {added:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn chip_names_the_working_copy_commit() {
let Some(dir) = scratch_repo("chip") else {
return;
};
let chip = branch(&dir).expect("a jj workspace always has a working-copy commit");
assert!(
chip.starts_with("@ "),
"the chip must open with jj's own marker: {chip}"
);
assert!(
!chip.contains("HEAD"),
"jj tracks no branch and leaves git detached, so HEAD would be a lie: {chip}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn chip_with_no_description_is_the_change_id_alone() {
let Some(dir) = scratch_repo("chip_empty_desc") else {
return;
};
let ws = workspace_root(&dir).expect("scratch_repo always sets up a jj workspace");
let m = meta(&ws).expect("a jj workspace always has a working-copy commit");
assert!(
m.summary.is_empty(),
"test assumption: scratch_repo's working copy is left undescribed: {m:?}"
);
let chip = branch(&dir).expect("a jj workspace always has a working-copy commit");
assert_eq!(
chip,
format!("@ {}", m.change),
"with no description the chip must be exactly the marker plus the change id"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn chip_includes_both_the_change_id_and_the_description() {
let Some(dir) = scratch_repo("chip_with_desc") else {
return;
};
let described = std::process::Command::new("jj")
.current_dir(&dir)
.env("HOME", &dir)
.env("JJ_USER", "konoma test")
.env("JJ_EMAIL", "test@example.invalid")
.args(["describe", "-m", "hand-set description for konoma test"])
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(described, "test setup: `jj describe` must succeed");
let ws = workspace_root(&dir).expect("scratch_repo always sets up a jj workspace");
let m = meta(&ws).expect("a jj workspace always has a working-copy commit");
assert_eq!(
m.summary, "hand-set description for konoma test",
"test setup: the describe above must have taken"
);
let chip = branch(&dir).expect("a jj workspace always has a working-copy commit");
assert!(
chip.contains(&m.change),
"the chip must include the real change id: chip={chip:?} change={}",
m.change
);
assert!(
chip.contains("hand-set description for konoma test"),
"the chip must include the real description: chip={chip:?}"
);
assert_eq!(
chip,
format!("@ {} hand-set description for konoma test", m.change),
"exact shape: marker, change id, description"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn dash_prefixed_path_is_never_read_as_a_flag() {
let Some(dir) = scratch_repo("dash") else {
return;
};
let st = statuses(&dir);
assert_eq!(
st.get(&dir.join("-dash.txt")),
Some(&FileStatus::Modified),
"an edited dash-prefixed file must be modified: {st:?}"
);
assert!(
!st.contains_key(&dir.join("-dash-kept.txt")),
"an untouched dash-prefixed file must carry no marker: {st:?}"
);
let lines = file_diff(&dir, &dir.join("-dash.txt"));
let text: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
assert!(
text.contains(&"dash before"),
"the parent's line must be read — a bare `-dash.txt` argument fails jj's own \
argument parser (exit 2, \"unexpected argument '-d'\"), which used to make \
revision_bytes return None here: {text:?}"
);
assert!(
text.contains(&"dash after"),
"the working copy's line must be present too: {text:?}"
);
assert!(
!lines.iter().all(|l| l.old_no.is_none()),
"every line carrying no old-side number is the bug-1 symptom: with the parent read \
silently failing, this file rendered as though it were entirely new: {lines:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn symlink_that_is_only_in_the_snapshot_reports_no_marker() {
let Some(dir) = scratch_repo("symlink") else {
return;
};
let link = dir.join("link.txt");
let st = statuses(&dir);
assert!(
!st.contains_key(&link),
"a symlink untouched since the last snapshot must carry no marker — before the fix \
it was excluded from the walk's `seen` set outright and so always fell out the other \
end as Deleted, regardless of whether anything about it had changed: {st:?}"
);
assert!(
changed_files(&dir).iter().all(|f| f.path != link),
"and must not appear in the changed-file list either"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn retargeted_symlink_is_reported_as_changed() {
let Some(dir) = scratch_repo("symlink_retarget") else {
return;
};
let link = dir.join("link.txt");
std::fs::remove_file(&link).expect("the fixture always creates this link");
std::os::unix::fs::symlink("-dash-kept.txt", &link).expect("retarget the link");
let st = statuses(&dir);
assert_eq!(
st.get(&link),
Some(&FileStatus::Modified),
"a retargeted symlink must be reported: its own timestamp is the only one that moved, \
so following the link to the target's timestamp hides the change entirely: {st:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn renamed_file_keeps_a_readable_pre_image_in_commit_diff() {
let Some(dir) = scratch_repo("rename") else {
return;
};
let seed = log(&dir, 10)
.into_iter()
.find(|c| c.summary == "seed")
.expect("the fixture always creates a commit described \"seed\"");
let lines = commit_diff(&dir, &seed.id);
let text: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
assert!(
text.contains(&"renamed.txt"),
"the renamed file must have an entry in the commit diff at all — with the summary \
line parsed as `line.split_once(' ')`, the rename's key came out as the mangled \
\"{{orig-for-rename.txt\", which read as absent on both sides and dropped the whole \
file from the diff: {text:?}"
);
assert!(
text.contains(&"line five"),
"the origin's content must be readable from the move-from path: {text:?}"
);
assert!(
text.contains(&"line five extended"),
"the destination's content must be present too: {text:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn symlink_created_after_the_last_snapshot_is_added_not_modified() {
let Some(dir) = scratch_repo("symlink_added") else {
return;
};
let link = dir.join("brand-new-link.txt");
std::os::unix::fs::symlink("kept.txt", &link)
.expect("create a fresh symlink jj has never tracked");
let st = statuses(&dir);
assert_eq!(
st.get(&link),
Some(&FileStatus::Added),
"a symlink jj has never tracked must be Added, not Modified: {st:?}"
);
let files = changed_files(&dir);
let entry = files
.iter()
.find(|f| f.path == link)
.expect("the new symlink must appear in the changed-file list");
assert_eq!(entry.status, FileStatus::Added);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn diff_template_verdict_is_scoped_to_its_own_workspace() {
use std::os::unix::fs::PermissionsExt;
let Some(broken) = scratch_repo("template_scope_broken") else {
return;
};
let Some(healthy) = scratch_repo_with_ambiguous_renames("template_scope_healthy") else {
let _ = std::fs::remove_dir_all(&broken);
return;
};
let store = broken.join(".jj/repo/store");
let restore =
std::fs::set_permissions(&store, std::fs::Permissions::from_mode(0o000)).is_ok();
let broken_verdict = restore.then(|| diff_template_available(&broken));
if restore {
let _ = std::fs::set_permissions(&store, std::fs::Permissions::from_mode(0o755));
}
let _ = std::fs::remove_dir_all(&broken);
assert!(
!broken.exists(),
"the unreadable workspace must be cleaned up before anything else can fail"
);
assert_eq!(
broken_verdict,
Some(false),
"a workspace whose store cannot be read has to answer that the template is unusable"
);
let seed = log(&healthy, 10)
.into_iter()
.find(|c| c.summary == "seed")
.expect("the fixture always creates a commit described \"seed\"");
let lines = commit_diff(&healthy, &seed.id);
let text: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
assert!(
text.contains(&"weird{b}.txt") && text.contains(&"plaindest.txt"),
"a healthy workspace probed after a broken one must still resolve both ambiguous \
renames -- this is the difference between the template path and `--summary`: {text:?}"
);
assert!(
!text.iter().any(|t| t.contains("src.txt => plaindest.txt")),
"and must not contain the path `--summary` would have synthesised: {text:?}"
);
let _ = std::fs::remove_dir_all(&healthy);
}
#[test]
fn rename_with_curly_braces_in_the_name_survives_commit_diff() {
let Some(dir) = scratch_repo_with_ambiguous_renames("curly") else {
return;
};
let seed = log(&dir, 10)
.into_iter()
.find(|c| c.summary == "seed")
.expect("the fixture always creates a commit described \"seed\"");
let lines = commit_diff(&dir, &seed.id);
let text: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
assert!(
text.contains(&"weird{b}.txt"),
"the renamed file must have a header entry in the commit diff at all: {text:?}"
);
assert!(
text.contains(&"line five"),
"the origin's content must be readable from the move-from path: {text:?}"
);
assert!(
text.contains(&"line five extended"),
"the destination's content must be present too: {text:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rename_with_the_literal_arrow_in_the_name_never_synthesizes_a_bogus_path() {
let Some(dir) = scratch_repo_with_ambiguous_renames("arrow") else {
return;
};
let seed = log(&dir, 10)
.into_iter()
.find(|c| c.summary == "seed")
.expect("the fixture always creates a commit described \"seed\"");
let lines = commit_diff(&dir, &seed.id);
let text: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
assert!(
text.contains(&"plaindest.txt"),
"the real destination must have a header entry: {text:?}"
);
assert!(
!text.iter().any(|t| t.contains("src.txt => plaindest.txt")),
"no entry may carry a path that does not exist on disk — this is the bug-shape \
mangled destination `split_rename` used to synthesize: {text:?}"
);
assert!(
!text.contains(&"arrow"),
"no entry may carry the bug-shape mangled origin either: {text:?}"
);
assert!(
text.contains(&"line five"),
"the origin's content (read from \"arrow => src.txt\") must be readable: {text:?}"
);
assert!(
text.contains(&"line five extended"),
"the destination's content must be present too: {text:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ignored_collapses_dirs_and_excludes_tracked() {
let Some(dir) = scratch_repo("ignored") else {
return;
};
std::fs::write(dir.join(".gitignore"), b"target/\nnode_modules/\n*.log\n")
.expect("write .gitignore");
std::fs::create_dir_all(dir.join("target/deep")).expect("mkdir target/deep");
std::fs::create_dir_all(dir.join("node_modules/pkg")).expect("mkdir node_modules/pkg");
std::fs::create_dir_all(dir.join("src")).expect("mkdir src");
std::fs::write(dir.join("target/a.o"), b"x").expect("write target/a.o");
std::fs::write(dir.join("target/deep/b.o"), b"x").expect("write target/deep/b.o");
std::fs::write(dir.join("node_modules/pkg/index.js"), b"x")
.expect("write node_modules/pkg/index.js");
std::fs::write(dir.join("app.log"), b"x").expect("write app.log");
std::fs::write(dir.join("src/main.rs"), b"fn main(){}\n").expect("write src/main.rs");
let set = ignored(&dir);
assert!(
set.contains(&dir.join("target")),
"target/ must collapse to one entry: {set:?}"
);
assert!(
set.contains(&dir.join("node_modules")),
"node_modules/ must collapse to one entry: {set:?}"
);
assert!(
set.contains(&dir.join("app.log")),
"the *.log rule must match the plain file: {set:?}"
);
assert!(
!set.contains(&dir.join("target/a.o")),
"a collapsed dir's contents must not be walked into: {set:?}"
);
assert!(
!set.contains(&dir.join("src/main.rs")),
"a tracked file must not read as ignored: {set:?}"
);
assert!(
!set.contains(&dir.join(".jj")),
"jj's own directory must never appear in the ignored set: {set:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn scratch_repo_merge(name: &str) -> Option<PathBuf> {
if !available() {
return None;
}
let dir = std::env::temp_dir().join(format!("konoma_jj_{name}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).ok()?;
let run_jj = |args: &[&str]| -> bool {
std::process::Command::new("jj")
.current_dir(&dir)
.env("HOME", &dir)
.env("JJ_USER", "konoma test")
.env("JJ_EMAIL", "test@example.invalid")
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
let jj_stdout = |args: &[&str]| -> Option<String> {
let out = std::process::Command::new("jj")
.current_dir(&dir)
.env("HOME", &dir)
.env("JJ_USER", "konoma test")
.env("JJ_EMAIL", "test@example.invalid")
.args(args)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
};
const CHANGE_ID: &[&str] = &[
"log",
"-r",
"@-",
"--no-graph",
"--ignore-working-copy",
"-T",
"change_id.shortest(12)",
];
if !run_jj(&["git", "init", "--no-colocate", "."]) {
return None;
}
std::fs::write(dir.join("only_a.txt"), b"line1\nline2\nline3\n").ok()?;
std::fs::write(dir.join("deleteme.txt"), b"delete me\n").ok()?;
if !run_jj(&["commit", "-m", "base"]) {
return None;
}
let base = jj_stdout(CHANGE_ID)?;
std::fs::write(dir.join("only_a.txt"), b"line1\nCHANGED-A\nline3\n").ok()?;
if !run_jj(&["commit", "-m", "sideA"]) {
return None;
}
let side_a = jj_stdout(CHANGE_ID)?;
if !run_jj(&["new", &base]) {
return None;
}
if !run_jj(&["commit", "-m", "sideB"]) {
return None;
}
let side_b = jj_stdout(CHANGE_ID)?;
if !run_jj(&["new", &side_a, &side_b]) {
return None;
}
std::thread::sleep(std::time::Duration::from_millis(1100));
std::fs::write(dir.join("only_a.txt"), b"line1\nCHANGED-A\nline3-edited\n").ok()?;
std::fs::remove_file(dir.join("deleteme.txt")).ok()?;
Some(dir)
}
#[test]
fn merge_working_copy_reports_real_edits_not_added() {
let Some(dir) = scratch_repo_merge("merge_status") else {
return;
};
let st = statuses(&dir);
assert_eq!(
st.get(&dir.join("only_a.txt")),
Some(&FileStatus::Modified),
"a file jj has tracked since sideA must read as an edit, not a fresh addition, even \
while @ is a merge: {st:?}"
);
assert_eq!(
st.get(&dir.join("deleteme.txt")),
Some(&FileStatus::Deleted),
"a file removed from a merge working copy must not simply vanish: {st:?}"
);
let files = changed_files(&dir);
let by_path: HashMap<PathBuf, FileStatus> =
files.iter().map(|f| (f.path.clone(), f.status)).collect();
assert_eq!(
by_path.get(&dir.join("only_a.txt")),
Some(&FileStatus::Modified),
"the changed-file list must agree with statuses(): {files:?}"
);
assert_eq!(
by_path.get(&dir.join("deleteme.txt")),
Some(&FileStatus::Deleted),
"the changed-file list must agree with statuses(): {files:?}"
);
let lines = file_diff(&dir, &dir.join("only_a.txt"));
let text: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
assert!(
text.contains(&"CHANGED-A"),
"the line shared with the resolved first parent (sideA) must survive as context, \
proving this is a real comparison and not a fabricated all-added one: {text:?}"
);
assert!(
!lines.iter().all(|l| l.old_no.is_none()),
"every line carrying no old-side number is the bug's symptom: with the parent lookup \
silently failing on the ambiguous `@-`, this file rendered as though entirely new: \
{lines:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn merge_commit_diff_is_not_all_added() {
let Some(dir) = scratch_repo_merge("merge_commit_diff") else {
return;
};
let committed = std::process::Command::new("jj")
.current_dir(&dir)
.env("HOME", &dir)
.env("JJ_USER", "konoma test")
.env("JJ_EMAIL", "test@example.invalid")
.args(["commit", "-m", "merge-edit"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
assert!(
committed,
"the fixture's jj must accept committing the merge's local edits"
);
let merge = log(&dir, 10)
.into_iter()
.find(|c| c.summary == "merge-edit")
.expect("the fixture always creates a commit described \"merge-edit\"");
let lines = commit_diff(&dir, &merge.id);
let text: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
assert!(
text.contains(&"only_a.txt"),
"the edited file must have a header entry in the commit diff at all: {text:?}"
);
assert!(
text.contains(&"CHANGED-A"),
"the line shared with the resolved first parent must survive as context: {text:?}"
);
assert!(
text.contains(&"deleteme.txt"),
"the deleted file must have a header entry in the commit diff — with the parent \
lookup failing, both sides read as absent and `push_file_diff` drops a file whose \
two sides are both None entirely, so this file vanished from the diff altogether: \
{text:?}"
);
assert!(
!lines.iter().all(|l| l.old_no.is_none()),
"every line carrying no old-side number is the bug's symptom: with `{{id}}-` failing \
on a merge commit, the whole diff rendered as though entirely new: {lines:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn file_diff_declines_to_follow_a_symlink_through_to_its_target() {
let Some(dir) = scratch_repo("symlink_diff") else {
return;
};
let link = dir.join("link_to_added.txt");
std::os::unix::fs::symlink("added.txt", &link).expect("create a fresh symlink");
let lines = file_diff(&dir, &link);
assert!(
lines.is_empty(),
"a symlink's diff must decline outright rather than let `canonicalize` silently \
substitute its target's identity and diff that instead — for a target absent from \
the parent, that substitution renders the whole target file as newly added: {lines:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn scratch_repo_pinned_trunk(name: &str) -> Option<(PathBuf, PathBuf)> {
if !available() {
return None;
}
let dir = std::env::temp_dir().join(format!("konoma_jj_{name}_{}", std::process::id()));
let remote = std::env::temp_dir().join(format!(
"konoma_jj_{name}_remote_{}.git",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&remote);
std::fs::create_dir_all(&dir).ok()?;
if !std::process::Command::new("git")
.args(["init", "--quiet", "--bare"])
.arg(&remote)
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return None;
}
let jj = |args: &[&str]| -> bool {
std::process::Command::new("jj")
.current_dir(&dir)
.env("HOME", &dir)
.env("JJ_USER", "konoma test")
.env("JJ_EMAIL", "test@example.invalid")
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
let jj_stdout = |args: &[&str]| -> Option<String> {
let out = std::process::Command::new("jj")
.current_dir(&dir)
.env("HOME", &dir)
.env("JJ_USER", "konoma test")
.env("JJ_EMAIL", "test@example.invalid")
.args(args)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
};
if !jj(&["git", "init", "--no-colocate", "."]) {
return None;
}
let remote_str = remote.to_str()?;
if !jj(&["git", "remote", "add", "origin", remote_str]) {
return None;
}
if !jj(&["commit", "-m", "c1"]) {
return None;
}
if !jj(&["commit", "-m", "c2"]) {
return None;
}
if !jj(&["commit", "-m", "c3trunktip"]) {
return None;
}
let c3 = jj_stdout(&[
"log",
"-r",
"@-",
"--no-graph",
"--ignore-working-copy",
"-T",
"commit_id",
])?;
if !jj(&["bookmark", "create", "-r", &c3, "main"]) {
return None;
}
if !jj(&["git", "push", "--remote", "origin", "--bookmark", "main"]) {
return None;
}
if !jj(&["commit", "-m", "branchA"]) {
return None;
}
if !jj(&["new", &c3]) {
return None;
}
if !jj(&["describe", "-m", "branchB-wc"]) {
return None;
}
Some((dir, remote))
}
#[test]
fn graph_default_revset_excludes_history_before_the_pinned_trunk() {
let Some((dir, remote)) = scratch_repo_pinned_trunk("graph_narrow") else {
return;
};
let default_rows = graph(&dir, None, 50);
let all_rows = graph(&dir, Some("all()"), 50);
let default_commits = default_rows.iter().filter(|r| r.commit.is_some()).count();
let all_commits = all_rows.iter().filter(|r| r.commit.is_some()).count();
assert_eq!(
default_commits, 3,
"the default revset must show only c3trunktip, branchA and the checked-out working \
copy — not root/c1/c2, which sit before the pin: {default_rows:?}"
);
assert_eq!(
all_commits, 6,
"all() must reach the full history, including root/c1/c2 the default hides: {all_rows:?}"
);
assert!(
default_commits < all_commits,
"the default revset exists to be narrower than all() — if this ever stops holding, \
graph()'s `None` path is no longer doing anything: default={default_commits} \
all={all_commits}"
);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&remote);
}
#[test]
fn graph_marks_the_checked_out_row_as_the_working_copy_kind() {
let Some((dir, remote)) = scratch_repo_pinned_trunk("graph_wc_kind") else {
return;
};
let rows = graph(&dir, Some("all()"), 50);
let wc_row = rows
.iter()
.find(|r| r.subject == "branchB-wc")
.expect("the fixture always describes its checked-out commit \"branchB-wc\"");
assert_eq!(
wc_row.node,
Some(crate::git::NodeKind::WorkingCopy),
"the checked-out commit must read as jj's working-copy kind: {wc_row:?}"
);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&remote);
}
#[test]
fn graph_root_row_is_immutable_with_the_synthesized_root_subject() {
let Some((dir, remote)) = scratch_repo_pinned_trunk("graph_root_row") else {
return;
};
let rows = graph(&dir, Some("all()"), 50);
let root_id = "0".repeat(40);
let root_row = rows
.iter()
.find(|r| r.commit.as_deref() == Some(root_id.as_str()))
.expect("all() must reach the repository's root commit");
assert_eq!(
root_row.short, "zzzzzzzz",
"jj's own shortest change id for the root commit (measured against jj 0.44.0)"
);
assert_eq!(
root_row.subject, "root()",
"the root commit's real subject is empty; graph() must have replaced it — the branch \
this test exists to cover: {root_row:?}"
);
assert_eq!(
root_row.node,
Some(crate::git::NodeKind::Immutable),
"the root commit is always immutable: {root_row:?}"
);
assert!(
root_row.author.is_empty() && root_row.date.is_empty(),
"the root commit has no real author or committer timestamp (epoch 0 is graph()'s \
sentinel for blanking both), so both must read empty rather than \"1970-01-01\" or \
a bogus name: {root_row:?}"
);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&remote);
}
#[test]
fn graph_node_col_always_points_at_the_row_kinds_own_glyph() {
let Some((dir, remote)) = scratch_repo_pinned_trunk("graph_node_col") else {
return;
};
let rows = graph(&dir, Some("all()"), 50);
let mut saw_immutable = false;
let mut saw_working_copy = false;
let mut saw_normal = false;
for r in &rows {
let (Some(kind), Some(col)) = (r.node, r.node_col) else {
continue; };
match kind {
crate::git::NodeKind::Immutable => saw_immutable = true,
crate::git::NodeKind::WorkingCopy => saw_working_copy = true,
crate::git::NodeKind::Normal => saw_normal = true,
crate::git::NodeKind::Merge | crate::git::NodeKind::Conflict => {}
}
let cell = r
.graph
.get(col)
.unwrap_or_else(|| panic!("node_col={col} is out of range for {r:?}"));
assert_eq!(
cell.0,
crate::ui::icons::node_glyph(kind, crate::vcs::VcsKind::Jj).to_string(),
"node_col must locate the cell holding this row's own glyph: {r:?}"
);
}
assert!(
saw_immutable && saw_working_copy && saw_normal,
"the fixture must actually exercise more than one NodeKind for this check to mean \
anything: immutable={saw_immutable} working_copy={saw_working_copy} \
normal={saw_normal}"
);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&remote);
}
fn scratch_repo_second_workspace(name: &str) -> Option<(PathBuf, PathBuf)> {
if !available() {
return None;
}
let dir = std::env::temp_dir().join(format!("konoma_jj_{name}_{}", std::process::id()));
let second =
std::env::temp_dir().join(format!("konoma_jj_{name}_ws2_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&second);
std::fs::create_dir_all(&dir).ok()?;
let jj = |args: &[&str]| -> bool {
std::process::Command::new("jj")
.current_dir(&dir)
.env("HOME", &dir)
.env("JJ_USER", "konoma test")
.env("JJ_EMAIL", "test@example.invalid")
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
if !jj(&["git", "init", "--no-colocate", "."]) {
return None;
}
std::fs::write(dir.join("a.txt"), b"hello\n").ok()?;
if !jj(&["commit", "-m", "genesis"]) {
return None;
}
if !jj(&["describe", "-m", "primary wc"]) {
return None;
}
let second_str = second.to_str()?;
if !jj(&["workspace", "add", "--name", "ws-second", second_str]) {
return None;
}
Some((dir, second))
}
#[test]
fn graph_from_the_primary_workspace_never_marks_a_second_workspaces_checkout_as_here() {
let Some((dir, second)) = scratch_repo_second_workspace("second_ws") else {
return;
};
let rows = graph(&dir, Some("all()"), 50);
let mine = rows.iter().find(|r| r.subject == "primary wc").expect(
"default's own working-copy commit must be in the log, described \"primary wc\"",
);
assert_eq!(
mine.node,
Some(crate::git::NodeKind::WorkingCopy),
"default's own checkout must read as the working-copy kind: {mine:?}"
);
let theirs = rows
.iter()
.find(|r| r.refs.contains("ws-second@"))
.expect("ws-second's checkout must appear, decorated with its workspace label");
assert_ne!(
theirs.node,
Some(crate::git::NodeKind::WorkingCopy),
"a second workspace's checkout is an ordinary commit from here — only ws-second's own \
`jj log` would see current_working_copy=true for it: {theirs:?}"
);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&second);
}
fn scratch_repo_bookmarks(name: &str) -> Option<(PathBuf, String)> {
if !available() {
return None;
}
let dir = std::env::temp_dir().join(format!("konoma_jj_{name}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).ok()?;
let jj = |args: &[&str]| -> bool {
std::process::Command::new("jj")
.current_dir(&dir)
.env("HOME", &dir)
.env("JJ_USER", "konoma test")
.env("JJ_EMAIL", "test@example.invalid")
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
let jj_stdout = |args: &[&str]| -> Option<String> {
let out = std::process::Command::new("jj")
.current_dir(&dir)
.env("HOME", &dir)
.env("JJ_USER", "konoma test")
.env("JJ_EMAIL", "test@example.invalid")
.args(args)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
};
if !jj(&["git", "init", "--no-colocate", "."]) {
return None;
}
std::fs::write(dir.join("f.txt"), b"hello\n").ok()?;
if !jj(&["commit", "-m", "genesis"]) {
return None;
}
let genesis = jj_stdout(&[
"log",
"-r",
"@-",
"--no-graph",
"--ignore-working-copy",
"-T",
"commit_id",
])?;
if !jj(&["bookmark", "create", "-r", "@-", "zeta"]) {
return None;
}
if !jj(&["bookmark", "create", "-r", "@-", "alpha"]) {
return None;
}
Some((dir, genesis))
}
#[test]
fn bookmarks_returns_names_sorted_and_none_marked_current() {
let Some((dir, _commit)) = scratch_repo_bookmarks("bookmarks_sorted") else {
return;
};
let b = bookmarks(&dir);
let names: Vec<&str> = b.iter().map(|x| x.name.as_str()).collect();
assert_eq!(
names,
vec!["alpha", "zeta"],
"the real bookmark names, in the order konoma's branch list shows them (`bookmarks()` \
own sort is pinned separately by `bookmark_list_dedups_and_sorts`, since real jj \
already returns them alphabetical): {b:?}"
);
assert!(
b.iter().all(|x| !x.is_current),
"a jj bookmark never follows the working copy the way a git branch follows HEAD, so \
none of them can be \"current\": {b:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn bookmark_tip_resolves_to_jjs_own_commit_id_and_none_for_a_missing_name() {
let Some((dir, commit)) = scratch_repo_bookmarks("bookmark_tip") else {
return;
};
assert_eq!(
bookmark_tip(&dir, "alpha"),
Some(commit.clone()),
"must match the commit id `jj log` itself reports for the bookmark"
);
assert_eq!(
bookmark_tip(&dir, "zeta"),
Some(commit),
"both bookmarks point at the same commit"
);
assert_eq!(
bookmark_tip(&dir, "does-not-exist"),
None,
"a name jj has never heard of must not resolve to anything"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn scratch_repo_commit_meta(name: &str) -> Option<(PathBuf, String, String)> {
if !available() {
return None;
}
let dir = std::env::temp_dir().join(format!("konoma_jj_{name}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).ok()?;
let jj = |args: &[&str]| -> bool {
std::process::Command::new("jj")
.current_dir(&dir)
.env("HOME", &dir)
.env("JJ_USER", "konoma test")
.env("JJ_EMAIL", "test@example.invalid")
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
let jj_stdout = |args: &[&str]| -> Option<String> {
let out = std::process::Command::new("jj")
.current_dir(&dir)
.env("HOME", &dir)
.env("JJ_USER", "konoma test")
.env("JJ_EMAIL", "test@example.invalid")
.args(args)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
};
if !jj(&["git", "init", "--no-colocate", "."]) {
return None;
}
std::fs::write(dir.join("f.txt"), b"hello\n").ok()?;
if !jj(&["commit", "-m", "placeholder"]) {
return None;
}
let mut child = std::process::Command::new("jj")
.current_dir(&dir)
.env("HOME", &dir)
.env("JJ_USER", "konoma test")
.env("JJ_EMAIL", "test@example.invalid")
.args(["describe", "-r", "@-", "--stdin"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok()?;
{
use std::io::Write;
child
.stdin
.take()?
.write_all(b"line one\nline two\nline three")
.ok()?;
}
if !child.wait().map(|s| s.success()).unwrap_or(false) {
return None;
}
let commit_id = jj_stdout(&[
"log",
"-r",
"@-",
"--no-graph",
"--ignore-working-copy",
"-T",
"commit_id",
])?;
let change_id = jj_stdout(&[
"log",
"-r",
"@-",
"--no-graph",
"--ignore-working-copy",
"-T",
"change_id.shortest(8)",
])?;
Some((dir, commit_id, change_id))
}
#[test]
fn commit_meta_reads_a_real_revision_by_either_id_and_keeps_a_multiline_message() {
let Some((dir, commit_id, change_id)) = scratch_repo_commit_meta("commit_meta") else {
return;
};
let by_commit = commit_meta(&dir, &commit_id).expect("a real commit id must resolve");
assert_eq!(by_commit.id, commit_id);
assert_eq!(
by_commit.short, change_id,
"the doc comment's own claim about `short`: it is the change id, not a short hash"
);
assert_eq!(by_commit.author, "konoma test");
assert!(
!by_commit.date.is_empty(),
"the date field must be populated for a real commit: {by_commit:?}"
);
assert_eq!(
by_commit.message, "line one\nline two\nline three",
"a multi-line description must survive whole, not truncate at the first line break"
);
let by_change =
commit_meta(&dir, &change_id).expect("the doc comment claims a change id resolves too");
assert_eq!(
by_change.id, commit_id,
"both lookups must land on the same commit"
);
assert_eq!(by_change.message, by_commit.message);
assert!(
commit_meta(&dir, "deadbeef00").is_none(),
"a revision jj has never heard of must resolve to nothing"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn scratch_repo_empty(name: &str) -> Option<PathBuf> {
if !available() {
return None;
}
let dir = std::env::temp_dir().join(format!("konoma_jj_{name}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).ok()?;
let ok = std::process::Command::new("jj")
.current_dir(&dir)
.env("HOME", &dir)
.env("JJ_USER", "konoma test")
.env("JJ_EMAIL", "test@example.invalid")
.args(["git", "init", "--no-colocate", "."])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !ok {
return None;
}
Some(dir)
}
#[test]
fn empty_repository_graph_reaches_the_root_without_panicking() {
let Some(dir) = scratch_repo_empty("empty_graph") else {
return;
};
let rows = graph(&dir, Some("all()"), 50);
let root_id = "0".repeat(40);
let root_row = rows
.iter()
.find(|r| r.commit.as_deref() == Some(root_id.as_str()))
.expect("even a repository with no commits of its own has jj's root commit");
assert_eq!(root_row.short, "zzzzzzzz");
assert_eq!(root_row.subject, "root()");
assert_eq!(root_row.node, Some(crate::git::NodeKind::Immutable));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn empty_repository_hub_reads_do_not_panic() {
let Some(dir) = scratch_repo_empty("empty_hub") else {
return;
};
let chip = branch(&dir);
assert!(
chip.as_deref().is_some_and(|c| c.starts_with("@ ")),
"even with nothing committed, jj always has a working-copy commit to name: {chip:?}"
);
assert!(
statuses(&dir).is_empty(),
"nothing has changed in a repository with no commits and no files"
);
assert!(
changed_files(&dir).is_empty(),
"nothing has changed in a repository with no commits and no files"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn worktree_origin_through_the_facade_is_always_none() {
let Some(dir) = scratch_repo("worktree_origin") else {
return;
};
crate::vcs::set_preference_for_test(Some(crate::vcs::Preference::Auto));
assert_eq!(
crate::vcs::detect(&dir),
crate::vcs::VcsKind::Jj,
"sanity: detect must pick jj here"
);
assert_eq!(
crate::vcs::worktree_origin(&dir),
None,
"jj models no worktree_origin — jj workspace is deliberately out of scope"
);
crate::vcs::set_preference_for_test(None);
let _ = std::fs::remove_dir_all(&dir);
}
fn scratch_repo_changed_files_order(name: &str) -> Option<PathBuf> {
if !available() {
return None;
}
let dir = std::env::temp_dir().join(format!("konoma_jj_{name}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).ok()?;
let jj = |args: &[&str]| {
std::process::Command::new("jj")
.current_dir(&dir)
.env("HOME", &dir)
.env("JJ_USER", "konoma test")
.env("JJ_EMAIL", "test@example.invalid")
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
if !jj(&["git", "init", "--no-colocate", "."]) {
return None;
}
std::fs::write(dir.join("mango.txt"), b"one\n").ok()?;
std::fs::write(dir.join("delta.txt"), b"one\n").ok()?;
std::fs::write(dir.join("sierra.txt"), b"one\n").ok()?;
if !jj(&["commit", "-m", "genesis"]) {
return None;
}
std::thread::sleep(std::time::Duration::from_millis(1100));
std::fs::write(dir.join("mango.txt"), b"two\n").ok()?;
std::fs::write(dir.join("sierra.txt"), b"two\n").ok()?;
std::fs::remove_file(dir.join("delta.txt")).ok()?;
std::fs::write(dir.join("zulu.txt"), b"new\n").ok()?;
std::fs::write(dir.join("kappa.txt"), b"new\n").ok()?;
std::fs::write(dir.join("alpha.txt"), b"new\n").ok()?;
Some(dir)
}
#[test]
fn changed_files_is_sorted_by_path() {
let Some(dir) = scratch_repo_changed_files_order("changed_files_order") else {
return;
};
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",
"sierra.txt",
"zulu.txt",
],
"changed_files must be sorted by path, not by scan's own walk-then-deletions order: \
{names:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn statuses_and_changed_files_agree_from_a_subdirectory() {
let Some(dir) = scratch_repo("subdir") else {
return;
};
let sub = dir.join("sub");
std::fs::create_dir_all(&sub).unwrap();
assert_eq!(
statuses(&sub),
statuses(&dir),
"same repository, same answer, no matter which directory root points at"
);
let to_tuples = |v: Vec<crate::git::ChangeEntry>| -> Vec<(PathBuf, FileStatus, bool)> {
v.into_iter()
.map(|e| (e.path, e.status, e.staged))
.collect()
};
assert_eq!(
to_tuples(changed_files(&sub)),
to_tuples(changed_files(&dir)),
"same repository, same answer, no matter which directory root points at"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_ordinary_directory_answers_empty_for_every_read() {
let dir =
std::env::temp_dir().join(format!("konoma_jj_no_marker_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for probe in [dir.as_path(), Path::new("/")] {
assert!(statuses(probe).is_empty(), "statuses {probe:?}");
assert!(ignored(probe).is_empty(), "ignored {probe:?}");
assert!(branch(probe).is_none(), "branch {probe:?}");
assert!(
crate::vcs::Jj.worktree_origin(probe).is_none(),
"worktree_origin {probe:?}"
);
assert!(
file_diff(probe, &probe.join("nope.txt")).is_empty(),
"file_diff {probe:?}"
);
assert!(changed_files(probe).is_empty(), "changed_files {probe:?}");
assert!(log(probe, 10).is_empty(), "log {probe:?}");
assert!(graph(probe, None, 100).is_empty(), "graph {probe:?}");
assert!(bookmarks(probe).is_empty(), "bookmarks {probe:?}");
assert!(
bookmark_tip(probe, "main").is_none(),
"bookmark_tip {probe:?}"
);
assert!(commit_meta(probe, "@").is_none(), "commit_meta {probe:?}");
assert!(commit_diff(probe, "@").is_empty(), "commit_diff {probe:?}");
}
std::fs::remove_dir_all(&dir).ok();
}
}