use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::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,
}
const META_TEMPLATE: &str = concat!(
r#"change_id.shortest(8) ++ "\t" ++ committer.timestamp().format("%s")"#,
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()?;
let mut f = line.split('\t');
let change = f.next()?.to_string();
let snapshot_epoch = f.next()?.trim().parse::<i64>().ok()?;
let summary = f.next().unwrap_or("").to_string();
if change.is_empty() {
return None;
}
Some(Meta {
change,
summary,
snapshot_epoch,
})
}
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)
})
}
fn known_changes(ws: &Path) -> HashMap<String, FileStatus> {
let mut map = HashMap::new();
let Some(out) = run(ws, &["diff", "--summary"]) else {
return map;
};
for line in out.lines() {
let Some((mark, path)) = line.split_once(' ') else {
continue;
};
let st = match mark {
"A" => FileStatus::Added,
"D" => FileStatus::Deleted,
"C" => FileStatus::Renamed,
_ => FileStatus::Modified,
};
map.insert(path.to_string(), st);
}
map
}
fn tracked(ws: &Path) -> HashSet<String> {
run(ws, &["file", "list", "-r", "@-"])
.map(|o| o.lines().map(|l| l.to_string()).collect())
.unwrap_or_default()
}
fn differs_from_parent(ws: &Path, rel: &str, disk: &Path) -> bool {
let Ok(now) = std::fs::read(disk) else {
return true; };
match parent_bytes(ws, rel) {
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);
let mut seen: HashSet<String> = HashSet::new();
let mut confirmed = 0usize;
for entry in walk(ws) {
if !entry.file_type().is_some_and(|t| t.is_file()) {
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 confirmed < CONFIRM_CAP {
confirmed += 1;
if differs_from_parent(ws, &rel, entry.path()) {
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;
};
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 before = parent_bytes(&ws, rel_str);
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) -> Option<Vec<u8>> {
revision_bytes(ws, "@-", 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::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" ++ description.first_line() ++ "\t""#,
r#" ++ author.name() ++ "\t" ++ 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","") ++ "\n""#,
);
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(|line| {
let f: Vec<&str> = line.split('\t').collect();
if f.len() < 10 {
return None;
}
Some(Row {
commit: f[0].to_string(),
parents: f[1].split_whitespace().map(str::to_string).collect(),
change: f[2].to_string(),
subject: f[3].to_string(),
author: f[4].to_string(),
date: f[5].to_string(),
epoch: f[6].trim().parse().unwrap_or(0),
workspaces: f[7].to_string(),
bookmarks: f[8].to_string(),
conflict: f[9].contains('c'),
immutable: f[9].contains('i'),
here: f[9].contains('w'),
})
})
.collect()
}
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();
};
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])?;
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,
})
}
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 Some(summary) = run(&ws, &["diff", "--summary", "-r", id]) else {
return out;
};
let parent = format!("{id}-");
for line in summary.lines() {
let Some((_, rel)) = line.split_once(' ') else {
continue;
};
let before = revision_bytes(&ws, &parent, rel);
let after = revision_bytes(&ws, id, rel);
crate::git::push_file_diff(
&mut out,
Path::new(rel),
before.as_deref(),
after.as_deref(),
true,
);
}
out
}
fn revision_bytes(ws: &Path, rev: &str, rel: &str) -> Option<Vec<u8>> {
let out = std::process::Command::new("jj")
.current_dir(ws)
.args(args(&["file", "show", "-r", rev, rel]))
.stdin(std::process::Stdio::null())
.output()
.ok()?;
out.status.success().then_some(out.stdout)
}
#[cfg(test)]
mod tests {
use super::*;
#[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());
}
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("kept.txt"), b"one\n").ok()?;
std::fs::write(dir.join("changed.txt"), b"before\n").ok()?;
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()?;
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(), 2, "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);
}
}