use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::Arc;
use anyhow::Result;
use vcs_runner::{run_jj, run_jj_with_retry, is_transient_error};
use super::shared::{assemble_results, process_files_parallel, FileProcessResult};
fn no_snapshot<'a>(args: &[&'a str]) -> Vec<&'a str> {
let mut full = Vec::with_capacity(args.len() + 1);
full.push("--ignore-working-copy");
full.extend_from_slice(args);
full
}
use crate::diff::{compute_four_way_diff, compute_four_way_diff_cancellable, DiffInput, FileDiff};
use crate::image_diff::is_image_file;
use crate::limits::DiffMetrics;
use crate::vcs::{ComparisonContext, DiffBase, RefreshResult, StackPosition, UpstreamDivergence, VcsBackend, VcsEventType, VcsWatchPaths};
pub struct JjVcs {
repo_path: PathBuf,
from_rev: String,
diff_base: AtomicU8,
}
fn resolve_base_rev(repo_path: &Path) -> String {
run_jj(repo_path, &[
"log", "-r", "trunk() ~ root()", "--no-graph",
"--limit", "1", "-T", "change_id.short(12)",
])
.map(|o| {
if o.stdout_lossy().trim().is_empty() {
"@-".to_string()
} else {
"trunk()".to_string()
}
})
.unwrap_or_else(|_| "@-".to_string())
}
fn resolve_fork_point(repo_path: &Path, from_rev: &str) -> Option<String> {
if from_rev != "trunk()" {
return None;
}
let args = no_snapshot(&[
"log", "-r", "heads(::trunk() & ::@)", "--no-graph",
"--limit", "1", "-T", "commit_id.short(12)",
]);
let fork_id = run_jj(repo_path, &args).ok()
.map(|o| o.stdout_lossy().trim().to_string())
.filter(|s| !s.is_empty())?;
let trunk_commit = {
let args = no_snapshot(&[
"log", "-r", "trunk()", "--no-graph", "--limit", "1",
"-T", "commit_id.short(12)",
]);
run_jj(repo_path, &args).ok()
.map(|o| o.stdout_lossy().trim().to_string())?
};
if fork_id == trunk_commit {
return None;
}
Some(fork_id)
}
fn compute_jj_divergence(
repo_path: &Path,
fork_point: &str,
) -> Option<UpstreamDivergence> {
let revset = format!("\"{}\"..trunk()", fork_point);
let count_args = no_snapshot(&[
"log", "-r", &revset,
"--no-graph", "-T", r#""\n""#,
]);
let behind_count = run_jj(repo_path, &count_args)
.map(|o| o.stdout_lossy().lines().filter(|l| !l.is_empty()).count())
.unwrap_or(0);
if behind_count == 0 {
return None;
}
let diff_args = no_snapshot(&[
"diff", "--from", fork_point, "--to", "trunk()", "--summary",
]);
let upstream_files = run_jj(repo_path, &diff_args)
.map(|o| {
parse_jj_summary(&o.stdout_lossy())
.into_iter()
.map(|f| f.path)
.collect()
})
.unwrap_or_default();
Some(UpstreamDivergence {
behind_count,
upstream_files,
})
}
struct StackTip {
change_id: String,
head_count: usize,
}
fn resolve_stack_tip(repo_path: &Path, from_rev: &str) -> Option<StackTip> {
if from_rev != "trunk()" {
return None;
}
let args = no_snapshot(&[
"log", "-r", "heads(trunk()..(@::))", "--no-graph",
"-T", r#"change_id.short(12) ++ "\n""#,
]);
let output = run_jj(repo_path, &args).ok()?;
let stdout = output.stdout_lossy();
let heads: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
if heads.is_empty() {
return None;
}
let at_id = get_change_id_static(repo_path, "@")?;
if heads.len() == 1 && heads[0].trim() == at_id.trim() {
return None;
}
Some(StackTip {
change_id: heads[0].trim().to_string(),
head_count: heads.len(),
})
}
struct BookmarkBoundary {
bookmark_name: String,
changed_files: HashSet<String>,
}
fn resolve_bookmark_boundary(repo_path: &Path, from_rev: &str) -> Option<BookmarkBoundary> {
if from_rev != "trunk()" {
return None;
}
let template = r#"bookmarks.join(",") ++ "\0" ++ change_id.short(12)"#;
let args = no_snapshot(&[
"log", "-r", "latest((@:: | @) & bookmarks())", "--no-graph", "--limit", "1",
"-T", template,
]);
let output = run_jj(repo_path, &args).ok()?;
let stdout = output.stdout_lossy();
let trimmed = stdout.trim();
if trimmed.is_empty() {
return None;
}
let parts: Vec<&str> = trimmed.splitn(2, '\0').collect();
if parts.len() < 2 {
return None;
}
let bookmark_name = parts[0].trim().trim_end_matches('*').to_string();
let current_bm_id = parts[1].trim().to_string();
if bookmark_name.is_empty() {
return None;
}
let revset = format!(
"latest((trunk()..\"{}\"-) & (bookmarks() | remote_bookmarks()))",
current_bm_id
);
let prev_args = no_snapshot(&[
"log", "-r", &revset, "--no-graph", "--limit", "1",
"-T", "change_id.short(12)",
]);
let boundary_id = run_jj(repo_path, &prev_args)
.ok()
.map(|o| o.stdout_lossy().trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| from_rev.to_string());
let range = format!("\"{}\"..@", boundary_id);
let diff_args = no_snapshot(&[
"diff", "-r", &range, "--name-only",
]);
let changed_files: HashSet<String> = run_jj(repo_path, &diff_args)
.map(|o| {
o.stdout_lossy()
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
})
.unwrap_or_default();
Some(BookmarkBoundary {
bookmark_name,
changed_files,
})
}
fn mark_bookmark_provenance(file_diff: &mut crate::diff::FileDiff, file_in_current_bookmark: bool) {
use crate::diff::LineSource;
for line in &mut file_diff.lines {
line.in_current_bookmark = Some(match line.source {
LineSource::Committed | LineSource::CanceledCommitted
| LineSource::DeletedBase | LineSource::Staged
| LineSource::DeletedCommitted | LineSource::CanceledStaged
| LineSource::Unstaged | LineSource::DeletedStaged => file_in_current_bookmark,
LineSource::Base if line.change_source.is_some() => file_in_current_bookmark,
LineSource::Base | LineSource::FileHeader | LineSource::Elided => false,
});
}
}
fn get_change_id_static(repo_path: &Path, rev: &str) -> Option<String> {
let args = no_snapshot(&[
"log", "-r", rev, "--no-graph", "--limit", "1",
"-T", "change_id.short(12)",
]);
run_jj(repo_path, &args)
.ok()
.map(|o| o.stdout_lossy().trim().to_string())
}
fn compute_stack_position(repo_path: &Path, tip_id: &str) -> Option<(usize, usize)> {
let revset = format!("trunk()..\"{}\"", tip_id);
let args = no_snapshot(&[
"log", "-r", &revset, "--no-graph",
"-T", r#"if(self.contained_in("@"), "@", ".") ++ "\n""#,
]);
let output = run_jj(repo_path, &args).ok()?;
let stdout = output.stdout_lossy();
let entries: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
let total = entries.len();
if total == 0 {
return None;
}
let index_from_top = entries.iter().position(|e| e.trim() == "@")?;
let current = total - index_from_top;
Some((current, total))
}
impl JjVcs {
pub fn new(repo_path: PathBuf) -> Result<Self> {
let from_rev = resolve_base_rev(&repo_path);
Ok(Self {
repo_path,
from_rev,
diff_base: AtomicU8::new(0),
})
}
fn load_diff_base(&self) -> DiffBase {
match self.diff_base.load(Ordering::Relaxed) {
0 => DiffBase::ForkPoint,
_ => DiffBase::TrunkTip,
}
}
fn run_jj(&self, args: &[&str]) -> Result<String> {
let output = run_jj(&self.repo_path, args)?;
Ok(output.stdout_lossy().into_owned())
}
fn run_jj_bytes(&self, args: &[&str]) -> Result<Option<Vec<u8>>> {
match run_jj(&self.repo_path, args) {
Ok(output) => Ok(Some(output.stdout)),
Err(e) if e.is_non_zero_exit() => Ok(None),
Err(e) => Err(e.into()),
}
}
fn get_changed_files_with_from(&self, from: &str, effective_to: &str) -> Result<Vec<ChangedFile>> {
let output = run_jj_with_retry(
&self.repo_path,
&["diff", "--from", from, "--to", effective_to, "--summary"],
is_transient_error,
)?;
Ok(parse_jj_summary(&output.stdout_lossy()))
}
fn get_binary_files_set(&self, from: &str, effective_to: &str) -> HashSet<String> {
let args = no_snapshot(&[
"diff", "--from", from, "--to", effective_to, "--stat",
]);
match run_jj_with_retry(&self.repo_path, &args, is_transient_error) {
Ok(output) => parse_binary_from_stat(&output.stdout_lossy()),
Err(_) => HashSet::new(),
}
}
fn get_file_bytes_at_rev(&self, file_path: &str, rev: &str) -> Result<Option<Vec<u8>>> {
self.run_jj_bytes(&["file", "show", "-r", rev, file_path])
}
fn get_change_id(&self, rev: &str) -> Result<String> {
let output = self.run_jj(&["log", "-r", rev, "-T", "change_id.short(12)", "--no-graph", "--limit", "1"])?;
Ok(output.trim().to_string())
}
#[cfg(test)]
fn get_bookmarks(&self, rev: &str) -> Option<String> {
let output = self.run_jj(&["log", "-r", rev, "-T", "bookmarks", "--no-graph", "--limit", "1"]).ok()?;
let trimmed = output.trim().trim_end_matches('*');
if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
}
#[cfg(test)]
fn rev_label(&self, rev: &str) -> String {
self.get_bookmarks(rev)
.unwrap_or_else(|| self.get_change_id(rev).unwrap_or_else(|_| rev.to_string()))
}
fn rev_metadata_no_snapshot(&self, rev: &str) -> (String, String) {
let template = r#"bookmarks ++ "\0" ++ change_id.short(12) ++ "\0" ++ change_id.shortest(4)"#;
let args = no_snapshot(&[
"log", "-r", rev, "-T", template, "--no-graph", "--limit", "1",
]);
match self.run_jj(&args) {
Ok(raw) => parse_rev_metadata(&raw),
Err(_) => (rev.to_string(), rev.to_string()),
}
}
fn is_colocated(&self) -> bool {
self.repo_path.join(".git").exists()
}
}
fn parse_rev_metadata(raw: &str) -> (String, String) {
let raw = raw.trim();
let parts: Vec<&str> = raw.splitn(3, '\0').collect();
if parts.len() >= 2 {
let bookmarks = parts[0].trim().trim_end_matches('*');
let change_id = parts[1].trim().to_string();
let shortest_id = parts.get(2).map(|s| s.trim()).unwrap_or("");
let label = if bookmarks.is_empty() {
if shortest_id.is_empty() { change_id.clone() } else { shortest_id.to_string() }
} else if shortest_id.is_empty() {
bookmarks.to_string()
} else {
format!("{shortest_id} ({bookmarks})")
};
(change_id, label)
} else {
(raw.to_string(), raw.to_string())
}
}
fn file_content_no_snapshot(repo_path: &Path, file_path: &str, rev: &str) -> Option<String> {
let args = no_snapshot(&["file", "show", "-r", rev, file_path]);
run_jj(repo_path, &args)
.ok()
.map(|o| o.stdout_lossy().into_owned())
}
fn process_jj_file(
repo_path: &Path,
from_rev: &str,
changed: &ChangedFile,
binary_files: &HashSet<String>,
tip_rev: Option<&str>,
bookmark_changed_files: Option<&HashSet<String>>,
cancel: &AtomicBool,
) -> FileProcessResult {
if cancel.load(Ordering::Relaxed) {
return FileProcessResult::Cancelled;
}
if binary_files.contains(&changed.path) {
if is_image_file(&changed.path) {
return FileProcessResult::Image { path: changed.path.clone() };
}
return FileProcessResult::Binary { path: changed.path.clone() };
}
let base_path = changed.old_path.as_deref().unwrap_or(&changed.path);
let base = file_content_no_snapshot(repo_path, base_path, from_rev);
if cancel.load(Ordering::Relaxed) {
return FileProcessResult::Cancelled;
}
let parent = file_content_no_snapshot(repo_path, &changed.path, "@-")
.or_else(|| {
changed.old_path.as_deref()
.and_then(|old| file_content_no_snapshot(repo_path, old, "@-"))
});
if cancel.load(Ordering::Relaxed) {
return FileProcessResult::Cancelled;
}
let index = file_content_no_snapshot(repo_path, &changed.path, "@");
if cancel.load(Ordering::Relaxed) {
return FileProcessResult::Cancelled;
}
let tip_content = tip_rev
.and_then(|tip| file_content_no_snapshot(repo_path, &changed.path, tip));
let working = match tip_rev {
Some(_) => tip_content.as_deref(),
None => index.as_deref(),
};
let mut file_diff = compute_four_way_diff_cancellable(
DiffInput {
path: &changed.path,
base: base.as_deref(),
head: parent.as_deref(),
index: index.as_deref(),
working,
old_path: changed.old_path.as_deref(),
},
cancel,
);
if let Some(bm_files) = bookmark_changed_files {
let in_bookmark = bm_files.contains(&changed.path);
mark_bookmark_provenance(&mut file_diff, in_bookmark);
}
FileProcessResult::Diff(file_diff)
}
pub fn get_repo_root(path: &Path) -> Result<PathBuf> {
let output = run_jj(path, &["root"])?;
let root = output.stdout_lossy().trim().to_string();
Ok(PathBuf::from(root))
}
impl crate::vcs::Vcs for JjVcs {
fn repo_path(&self) -> &Path {
&self.repo_path
}
fn comparison_context(&self) -> Result<ComparisonContext> {
let template = r#"bookmarks ++ "\0" ++ change_id.short(12) ++ "\0" ++ change_id.shortest(4)"#;
let from_label = match run_jj_with_retry(
&self.repo_path,
&["log", "-r", &self.from_rev, "-T", template, "--no-graph", "--limit", "1"],
is_transient_error,
) {
Ok(output) => parse_rev_metadata(&output.stdout_lossy()).1,
Err(_) => self.from_rev.clone(),
};
let to_args = no_snapshot(&[
"log", "-r", "@", "-T", template, "--no-graph", "--limit", "1",
]);
let to_label = match run_jj_with_retry(&self.repo_path, &to_args, is_transient_error) {
Ok(output) => parse_rev_metadata(&output.stdout_lossy()).1,
Err(_) => "@".to_string(),
};
Ok(ComparisonContext { from_label, to_label, stack_position: None, vcs_backend: VcsBackend::Jj, bookmark_name: None, divergence: None })
}
fn refresh(&self, cancel_flag: &Arc<AtomicBool>) -> Result<RefreshResult> {
let fork_point = resolve_fork_point(&self.repo_path, &self.from_rev);
let divergence = fork_point.as_deref()
.and_then(|fp| compute_jj_divergence(&self.repo_path, fp));
let effective_from = match self.load_diff_base() {
DiffBase::ForkPoint => fork_point.as_deref().unwrap_or(&self.from_rev),
DiffBase::TrunkTip => &self.from_rev,
};
let stack_tip = resolve_stack_tip(&self.repo_path, &self.from_rev);
let effective_to = stack_tip
.as_ref()
.map(|t| t.change_id.as_str())
.unwrap_or("@");
let tip_rev = stack_tip.as_ref().map(|t| t.change_id.as_str());
let bookmark_boundary = resolve_bookmark_boundary(&self.repo_path, &self.from_rev);
let bookmark_changed_files = bookmark_boundary.as_ref().map(|b| &b.changed_files);
let changed_files = self.get_changed_files_with_from(effective_from, effective_to)?;
if cancel_flag.load(Ordering::Relaxed) {
anyhow::bail!("refresh cancelled");
}
let binary_files = self.get_binary_files_set(effective_from, effective_to);
if cancel_flag.load(Ordering::Relaxed) {
anyhow::bail!("refresh cancelled");
}
let results = process_files_parallel(&changed_files, cancel_flag, |changed| {
process_jj_file(
&self.repo_path,
effective_from,
changed,
&binary_files,
tip_rev,
bookmark_changed_files,
cancel_flag,
)
});
if cancel_flag.load(Ordering::Relaxed) {
anyhow::bail!("refresh cancelled");
}
let assembled = assemble_results(results);
let files = assembled.files;
let all_lines = assembled.lines;
let stack_position = stack_tip.as_ref().and_then(|tip| {
let (current, total) = compute_stack_position(&self.repo_path, &tip.change_id)?;
Some(StackPosition {
current,
total,
head_count: tip.head_count,
})
});
let metrics = DiffMetrics {
total_lines: all_lines.len(),
file_count: files.len(),
};
let (base_identifier, base_label_str) =
self.rev_metadata_no_snapshot(effective_from);
let (_, current_branch_str) = self.rev_metadata_no_snapshot("@");
let file_paths: Vec<&str> = files
.iter()
.filter_map(|f| f.lines.first())
.filter_map(|l| l.file_path.as_deref())
.collect();
let file_links = crate::file_links::compute_file_links(&file_paths);
Ok(RefreshResult {
files,
lines: all_lines,
base_identifier,
base_label: Some(base_label_str),
current_branch: Some(current_branch_str),
metrics,
file_links,
stack_position,
bookmark_name: bookmark_boundary.map(|b| b.bookmark_name),
revision_id: None,
divergence,
})
}
fn single_file_diff(&self, file_path: &str) -> Option<FileDiff> {
let fork_point = resolve_fork_point(&self.repo_path, &self.from_rev);
let effective_from = match self.load_diff_base() {
DiffBase::ForkPoint => fork_point.as_deref().unwrap_or(&self.from_rev),
DiffBase::TrunkTip => &self.from_rev,
};
let stack_tip = resolve_stack_tip(&self.repo_path, &self.from_rev);
let effective_to = stack_tip
.as_ref()
.map(|t| t.change_id.as_str())
.unwrap_or("@");
let changed_files = self.get_changed_files_with_from(effective_from, effective_to).ok()?;
let changed = changed_files.iter().find(|f| f.path == file_path);
let old_path = changed.and_then(|f| f.old_path.as_deref());
let base_path = old_path.unwrap_or(file_path);
let base = file_content_no_snapshot(&self.repo_path, base_path, effective_from);
let parent = file_content_no_snapshot(&self.repo_path, file_path, "@-")
.or_else(|| {
old_path.and_then(|old| file_content_no_snapshot(&self.repo_path, old, "@-"))
});
let index = file_content_no_snapshot(&self.repo_path, file_path, "@");
let tip_content = stack_tip
.as_ref()
.and_then(|t| file_content_no_snapshot(&self.repo_path, file_path, &t.change_id));
let has_tip = stack_tip.is_some();
if base.is_none() && index.is_none() && tip_content.is_none() {
return None;
}
let binary_files = self.get_binary_files_set(effective_from, effective_to);
if binary_files.contains(file_path) {
return None;
}
let working = if has_tip { tip_content.as_deref() } else { index.as_deref() };
let mut file_diff = compute_four_way_diff(DiffInput {
path: file_path,
base: base.as_deref(),
head: parent.as_deref(),
index: index.as_deref(),
working,
old_path,
});
if let Some(boundary) = resolve_bookmark_boundary(&self.repo_path, &self.from_rev) {
let in_bookmark = boundary.changed_files.contains(file_path);
mark_bookmark_provenance(&mut file_diff, in_bookmark);
}
Some(file_diff)
}
fn base_identifier(&self) -> Result<String> {
self.get_change_id(&self.from_rev)
}
fn current_revision_id(&self) -> Result<String> {
self.run_jj(&[
"--ignore-working-copy",
"log", "-r", "@", "--no-graph", "--limit", "1",
"-T", "change_id.short(12)",
])
.map(|s| s.trim().to_string())
}
fn base_file_bytes(&self, file_path: &str) -> Result<Option<Vec<u8>>> {
self.get_file_bytes_at_rev(file_path, &self.from_rev)
}
fn working_file_bytes(&self, file_path: &str) -> Result<Option<Vec<u8>>> {
self.get_file_bytes_at_rev(file_path, "@")
}
fn binary_files(&self) -> HashSet<String> {
self.get_binary_files_set(&self.from_rev, "@")
}
fn fetch(&self) -> Result<()> {
if self.is_colocated() {
self.run_jj(&["git", "fetch"])?;
}
Ok(())
}
fn has_conflicts(&self) -> Result<bool> {
Ok(false)
}
fn is_locked(&self) -> bool {
false
}
fn watch_paths(&self) -> VcsWatchPaths {
let jj_dir = self.repo_path.join(".jj");
VcsWatchPaths {
files: vec![jj_dir.join("working_copy/checkout")],
recursive_dirs: vec![jj_dir.join("repo/op_store")],
}
}
fn classify_event(&self, path: &Path) -> VcsEventType {
let relative = path.strip_prefix(&self.repo_path).unwrap_or(path);
let first = relative.components().next().map(|c| c.as_os_str());
if first.is_some_and(|c| c == ".jj") {
let path_str = relative.to_string_lossy();
return if path_str.contains("working_copy/") {
VcsEventType::RevisionChange
} else {
VcsEventType::Internal
};
}
if first.is_some_and(|c| c == ".git") && self.is_colocated() {
return VcsEventType::Internal;
}
VcsEventType::Source
}
fn backend(&self) -> VcsBackend {
VcsBackend::Jj
}
fn set_diff_base(&self, base: DiffBase) {
let val = match base {
DiffBase::ForkPoint => 0,
DiffBase::TrunkTip => 1,
};
self.diff_base.store(val, Ordering::Relaxed);
}
}
#[derive(Debug, Clone)]
struct ChangedFile {
path: String,
old_path: Option<String>,
}
fn parse_jj_summary(output: &str) -> Vec<ChangedFile> {
output
.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() {
return None;
}
let first = line.chars().next()?;
if !matches!(first, 'M' | 'A' | 'D' | 'R' | 'C') {
return None;
}
let rest = line[1..].trim();
if rest.is_empty() {
return None;
}
if first == 'R' {
parse_rename(rest)
} else {
Some(ChangedFile { path: rest.to_string(), old_path: None })
}
})
.collect()
}
fn parse_rename(s: &str) -> Option<ChangedFile> {
let s = s.strip_prefix('{')?.strip_suffix('}')?;
let (old, new) = s.split_once(" => ")?;
let old = old.trim();
let new = new.trim();
if new.is_empty() {
return None;
}
Some(ChangedFile {
path: new.to_string(),
old_path: Some(old.to_string()),
})
}
fn parse_binary_from_stat(output: &str) -> HashSet<String> {
output
.lines()
.filter(|line| line.contains("(binary)"))
.filter_map(|line| {
let raw_path = line.split('|').next()?.trim();
if raw_path.is_empty() {
return None;
}
let path = if let Some(inner) = raw_path.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
inner.split(" => ").last().unwrap_or(inner).trim()
} else if raw_path.contains(" => ") {
raw_path.split(" => ").last().unwrap_or(raw_path).trim()
} else {
raw_path
};
if path.is_empty() { None } else { Some(path.to_string()) }
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use crate::diff::LineSource;
use crate::vcs::Vcs;
#[test]
fn test_parse_summary_modified() {
let files = parse_jj_summary("M file.txt\n");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "file.txt");
assert!(files[0].old_path.is_none());
}
#[test]
fn test_parse_summary_added() {
let files = parse_jj_summary("A new_file.txt\n");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "new_file.txt");
assert!(files[0].old_path.is_none());
}
#[test]
fn test_parse_summary_deleted() {
let files = parse_jj_summary("D old_file.txt\n");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "old_file.txt");
}
#[test]
fn test_parse_summary_renamed() {
let files = parse_jj_summary("R {old_name.txt => new_name.txt}\n");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "new_name.txt");
assert_eq!(files[0].old_path.as_deref(), Some("old_name.txt"));
}
#[test]
fn test_parse_summary_renamed_with_directory() {
let files = parse_jj_summary("R {src/old.rs => src/new.rs}\n");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "src/new.rs");
assert_eq!(files[0].old_path.as_deref(), Some("src/old.rs"));
}
#[test]
fn test_parse_summary_multiple() {
let output = "M file1.txt\nA file2.txt\nD file3.txt\n";
let files = parse_jj_summary(output);
assert_eq!(files.len(), 3);
assert_eq!(files[0].path, "file1.txt");
assert_eq!(files[1].path, "file2.txt");
assert_eq!(files[2].path, "file3.txt");
}
#[test]
fn test_parse_summary_mixed_with_rename() {
let output = "M file.txt\nR {old.rs => new.rs}\nA added.txt\n";
let files = parse_jj_summary(output);
assert_eq!(files.len(), 3);
assert_eq!(files[0].path, "file.txt");
assert!(files[0].old_path.is_none());
assert_eq!(files[1].path, "new.rs");
assert_eq!(files[1].old_path.as_deref(), Some("old.rs"));
assert_eq!(files[2].path, "added.txt");
assert!(files[2].old_path.is_none());
}
#[test]
fn test_parse_summary_empty() {
let files = parse_jj_summary("");
assert!(files.is_empty());
}
#[test]
fn test_parse_summary_skips_blank_lines() {
let output = "M file.txt\n\nA other.txt\n";
let files = parse_jj_summary(output);
assert_eq!(files.len(), 2);
}
#[test]
fn test_parse_summary_path_with_spaces() {
let files = parse_jj_summary("M path with spaces.txt\n");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "path with spaces.txt");
}
#[test]
fn test_parse_rename_malformed_no_braces() {
let files = parse_jj_summary("R old.txt => new.txt\n");
assert!(files.is_empty(), "rename without braces should be skipped");
}
#[test]
fn test_parse_rename_malformed_no_arrow() {
let files = parse_jj_summary("R {old.txt new.txt}\n");
assert!(files.is_empty(), "rename without => should be skipped");
}
#[test]
fn test_parse_binary_from_stat_detects_binary() {
let output = "image.png | (binary)\nfile.txt | 2 +-\n1 file changed\n";
let binaries = parse_binary_from_stat(output);
assert!(binaries.contains("image.png"));
assert!(!binaries.contains("file.txt"));
}
#[test]
fn test_parse_binary_from_stat_empty() {
let binaries = parse_binary_from_stat("file.txt | 2 +-\n");
assert!(binaries.is_empty());
}
#[test]
fn test_parse_binary_from_stat_multiple() {
let output = "a.png | (binary)\nb.jpg | (binary)\nc.txt | 1 +\n";
let binaries = parse_binary_from_stat(output);
assert_eq!(binaries.len(), 2);
assert!(binaries.contains("a.png"));
assert!(binaries.contains("b.jpg"));
}
#[test]
fn test_parse_binary_from_stat_renamed_with_braces() {
let output = "{original.bin => renamed.bin} | (binary)\n";
let binaries = parse_binary_from_stat(output);
assert!(binaries.contains("renamed.bin"), "should extract new name from rename");
assert!(!binaries.contains("{original.bin => renamed.bin}"), "should not store raw rename format");
}
#[test]
fn test_parse_binary_from_stat_renamed_without_braces() {
let output = "original.bin => renamed.bin | (binary)\n";
let binaries = parse_binary_from_stat(output);
assert!(binaries.contains("renamed.bin"), "should extract new name from arrow format");
}
#[test]
fn test_parse_rev_metadata_with_bookmark() {
let (change_id, label) = parse_rev_metadata("my-feature\0abcdef123456\n");
assert_eq!(change_id, "abcdef123456");
assert_eq!(label, "my-feature");
}
#[test]
fn test_parse_rev_metadata_without_bookmark() {
let (change_id, label) = parse_rev_metadata("\0abcdef123456\n");
assert_eq!(change_id, "abcdef123456");
assert_eq!(label, "abcdef123456");
}
#[test]
fn test_parse_rev_metadata_strips_tracking_marker() {
let (change_id, label) = parse_rev_metadata("main*\0abcdef123456\n");
assert_eq!(change_id, "abcdef123456");
assert_eq!(label, "main");
}
#[test]
fn test_parse_rev_metadata_empty_string() {
let (change_id, label) = parse_rev_metadata("");
assert_eq!(change_id, "");
assert_eq!(label, "");
}
#[test]
fn test_parse_rev_metadata_no_separator() {
let (change_id, label) = parse_rev_metadata("fallback_text");
assert_eq!(change_id, "fallback_text");
assert_eq!(label, "fallback_text");
}
#[test]
fn test_no_snapshot_prepends_flag() {
let args = no_snapshot(&["diff", "--from", "@-", "--to", "@"]);
assert_eq!(args[0], "--ignore-working-copy");
assert_eq!(args[1], "diff");
assert_eq!(args.len(), 6);
}
#[test]
fn test_classify_source_file() {
let vcs = JjVcs::new(PathBuf::from("/repo")).unwrap();
assert_eq!(
vcs.classify_event(Path::new("/repo/src/main.rs")),
VcsEventType::Source
);
}
#[test]
fn test_classify_jj_op_store_as_internal() {
let vcs = JjVcs::new(PathBuf::from("/repo")).unwrap();
assert_eq!(
vcs.classify_event(Path::new("/repo/.jj/repo/op_store/heads")),
VcsEventType::Internal
);
}
#[test]
fn test_classify_jj_working_copy() {
let vcs = JjVcs::new(PathBuf::from("/repo")).unwrap();
assert_eq!(
vcs.classify_event(Path::new("/repo/.jj/working_copy/checkout")),
VcsEventType::RevisionChange
);
}
#[test]
fn test_classify_jj_internal() {
let vcs = JjVcs::new(PathBuf::from("/repo")).unwrap();
assert_eq!(
vcs.classify_event(Path::new("/repo/.jj/repo/store/something")),
VcsEventType::Internal
);
}
#[test]
fn test_classify_path_outside_repo() {
let vcs = JjVcs::new(PathBuf::from("/repo")).unwrap();
assert_eq!(
vcs.classify_event(Path::new("/other/file.rs")),
VcsEventType::Source
);
}
#[test]
fn test_classify_git_index_as_internal_in_colocated_repo() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join(".jj")).unwrap();
std::fs::create_dir_all(temp.path().join(".git")).unwrap();
let vcs = JjVcs::new(temp.path().to_path_buf()).unwrap();
assert_eq!(
vcs.classify_event(&temp.path().join(".git/index")),
VcsEventType::Internal
);
}
#[test]
fn test_classify_git_objects_as_internal_in_colocated_repo() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join(".jj")).unwrap();
std::fs::create_dir_all(temp.path().join(".git")).unwrap();
let vcs = JjVcs::new(temp.path().to_path_buf()).unwrap();
assert_eq!(
vcs.classify_event(&temp.path().join(".git/objects/ab/cd1234")),
VcsEventType::Internal
);
}
#[test]
fn test_classify_git_refs_as_internal_in_colocated_repo() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join(".jj")).unwrap();
std::fs::create_dir_all(temp.path().join(".git")).unwrap();
let vcs = JjVcs::new(temp.path().to_path_buf()).unwrap();
assert_eq!(
vcs.classify_event(&temp.path().join(".git/refs/jj/keep/abc123")),
VcsEventType::Internal
);
}
#[test]
fn test_classify_git_head_as_internal_in_colocated_repo() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join(".jj")).unwrap();
std::fs::create_dir_all(temp.path().join(".git")).unwrap();
let vcs = JjVcs::new(temp.path().to_path_buf()).unwrap();
assert_eq!(
vcs.classify_event(&temp.path().join(".git/HEAD")),
VcsEventType::Internal
);
}
#[test]
fn test_classify_git_path_as_source_in_non_colocated_repo() {
let vcs = JjVcs::new(PathBuf::from("/repo")).unwrap();
assert_eq!(
vcs.classify_event(Path::new("/repo/.git/index")),
VcsEventType::Source
);
}
#[test]
fn test_classify_source_file_in_colocated_repo() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join(".jj")).unwrap();
std::fs::create_dir_all(temp.path().join(".git")).unwrap();
let vcs = JjVcs::new(temp.path().to_path_buf()).unwrap();
assert_eq!(
vcs.classify_event(&temp.path().join("src/main.rs")),
VcsEventType::Source
);
}
#[test]
fn test_watch_paths() {
use crate::vcs::Vcs;
let vcs = JjVcs::new(PathBuf::from("/repo")).unwrap();
let paths = vcs.watch_paths();
assert!(paths.files.contains(&PathBuf::from("/repo/.jj/working_copy/checkout")));
assert!(paths.recursive_dirs.contains(&PathBuf::from("/repo/.jj/repo/op_store")));
}
fn jj_available() -> bool {
Command::new("jj").arg("--version").output().is_ok_and(|o| o.status.success())
}
#[test]
fn test_jj_refresh_detects_modified_file() {
if !jj_available() { return; }
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "initial\n").unwrap();
Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "modified\n").unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
let cancel = Arc::new(AtomicBool::new(false));
let result = vcs.refresh(&cancel).unwrap();
assert!(!result.files.is_empty(), "should detect changed file");
assert!(!result.lines.is_empty(), "should produce diff lines");
let has_staged = result.lines.iter().any(|l| l.source == LineSource::Staged);
assert!(has_staged, "modified lines should have Staged source (current commit)");
let header = &result.lines[0];
assert_eq!(header.source, LineSource::FileHeader, "first line should be file header");
assert!(!header.content.contains("(deleted)"),
"modified file should not have deletion header, got: {}", header.content);
}
#[test]
fn test_jj_refresh_detects_new_file() {
if !jj_available() { return; }
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("existing.txt"), "content\n").unwrap();
Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("new_file.txt"), "new content\n").unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
let cancel = Arc::new(AtomicBool::new(false));
let result = vcs.refresh(&cancel).unwrap();
assert!(!result.files.is_empty(), "should detect new file");
let has_new_content = result.lines.iter().any(|l| l.content.contains("new content"));
assert!(has_new_content, "should contain new file content in diff lines");
}
#[test]
fn test_jj_comparison_context() {
if !jj_available() { return; }
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "content\n").unwrap();
Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
let ctx = vcs.comparison_context().unwrap();
assert!(!ctx.to_label.is_empty(), "should have a to label");
assert!(!ctx.from_label.is_empty(), "should have a from label");
let base_id = vcs.base_identifier().unwrap();
assert!(!base_id.is_empty(), "should have a base identifier");
}
#[test]
fn test_jj_base_file_bytes() {
if !jj_available() { return; }
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "original\n").unwrap();
Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "changed\n").unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
let base = vcs.base_file_bytes("file.txt").unwrap();
assert_eq!(base.unwrap(), b"original\n");
let working = vcs.working_file_bytes("file.txt").unwrap();
assert_eq!(working.unwrap(), b"changed\n");
}
#[test]
fn test_jj_refresh_detects_deleted_file() {
if !jj_available() { return; }
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("doomed.txt"), "goodbye\n").unwrap();
Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
std::fs::remove_file(repo.join("doomed.txt")).unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
let cancel = Arc::new(AtomicBool::new(false));
let result = vcs.refresh(&cancel).unwrap();
assert!(!result.files.is_empty(), "should detect deleted file");
let header = &result.lines[0];
assert!(header.content.contains("(deleted)"),
"deleted file should have deletion header, got: {}", header.content);
let has_deleted_source = result.lines.iter().any(|l| l.source == LineSource::DeletedCommitted);
assert!(has_deleted_source, "deleted file lines should have DeletedCommitted source");
}
#[test]
fn test_jj_single_file_diff_handles_rename() {
if !jj_available() { return; }
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("original.txt"), "line1\nline2\nline3\nline4\n").unwrap();
Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
std::fs::rename(repo.join("original.txt"), repo.join("renamed.txt")).unwrap();
std::fs::write(repo.join("renamed.txt"), "line1\nline2\nline3\nmodified\n").unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
let diff = vcs.single_file_diff("renamed.txt");
assert!(diff.is_some(), "should produce a diff for renamed file");
let diff = diff.unwrap();
let header = &diff.lines[0];
assert!(
header.content.contains("original.txt"),
"rename header should reference old filename, got: {}",
header.content
);
}
#[test]
fn test_jj_get_repo_root() {
if !jj_available() { return; }
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
let root = get_repo_root(repo).unwrap();
let expected = repo.canonicalize().unwrap();
let actual = root.canonicalize().unwrap();
assert_eq!(actual, expected);
}
#[test]
fn test_jj_refresh_returns_base_label_with_bookmark() {
if !jj_available() { return; }
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "content\n").unwrap();
Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
Command::new("jj").args(["bookmark", "set", "my-base", "-r", "@-"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "changed\n").unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
let cancel = Arc::new(AtomicBool::new(false));
let result = vcs.refresh(&cancel).unwrap();
let base_label = result.base_label.expect("should have base_label");
assert!(base_label.contains("(my-base)"),
"label should contain bookmark in parens, got: {base_label}");
assert!(base_label.ends_with(')'),
"label should end with ), got: {base_label}");
}
#[test]
fn test_jj_refresh_returns_base_label_as_change_id_without_bookmark() {
if !jj_available() { return; }
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "content\n").unwrap();
Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "changed\n").unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
let cancel = Arc::new(AtomicBool::new(false));
let result = vcs.refresh(&cancel).unwrap();
let base_label = result.base_label.expect("should have base_label");
assert!(!base_label.is_empty());
assert!(result.base_identifier.starts_with(&base_label),
"without bookmark, base_label should be a prefix of base_identifier: label={base_label}, id={}", result.base_identifier);
}
#[test]
fn test_jj_bookmark_strips_tracking_marker() {
if !jj_available() { return; }
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "content\n").unwrap();
Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
Command::new("jj").args(["bookmark", "set", "my-branch"]).current_dir(repo).output().unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
let label = vcs.rev_label("@");
assert_eq!(label, "my-branch", "should strip trailing * from bookmark name");
}
#[test]
fn test_jj_refresh_parallel_with_multiple_files() {
if !jj_available() { return; }
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
for i in 0..6 {
std::fs::write(repo.join(format!("file{i}.txt")), "initial\n").unwrap();
}
Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
for i in 0..6 {
std::fs::write(repo.join(format!("file{i}.txt")), format!("modified {i}\n")).unwrap();
}
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
let cancel = Arc::new(AtomicBool::new(false));
let result = vcs.refresh(&cancel).unwrap();
assert_eq!(result.files.len(), 6, "should detect all 6 changed files");
assert!(!result.base_identifier.is_empty());
}
#[test]
fn test_jj_rev_metadata_no_snapshot_with_bookmark() {
if !jj_available() { return; }
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "content\n").unwrap();
Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
Command::new("jj").args(["bookmark", "set", "test-bm", "-r", "@-"]).current_dir(repo).output().unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
let (change_id, label) = vcs.rev_metadata_no_snapshot("@-");
assert!(!change_id.is_empty());
assert!(label.contains("(test-bm)"),
"label should contain bookmark in parens, got: {label}");
assert!(label.ends_with(')'),
"label should end with ), got: {label}");
}
#[test]
fn test_jj_rev_metadata_no_snapshot_without_bookmark() {
if !jj_available() { return; }
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "content\n").unwrap();
Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
let (change_id, label) = vcs.rev_metadata_no_snapshot("@-");
assert!(!change_id.is_empty());
assert!(change_id.starts_with(&label),
"without bookmark, label should be shortest prefix of change_id: label={label}, id={change_id}");
assert!(label.len() >= 4, "shortest ID should be at least 4 chars, got: {label}");
}
#[test]
fn test_resolve_base_rev_fallback_without_remote() {
if !jj_available() { return; }
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
let rev = resolve_base_rev(repo);
assert_eq!(rev, "@-", "should fall back to @- when no remote tracking bookmarks");
}
#[test]
fn test_resolve_base_rev_uses_trunk_with_remote() {
if !jj_available() { return; }
let remote_dir = tempfile::TempDir::new().unwrap();
Command::new("git").args(["init", "--bare"]).current_dir(remote_dir.path()).output().unwrap();
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "content\n").unwrap();
Command::new("jj").args(["commit", "-m", "initial"]).current_dir(repo).output().unwrap();
Command::new("jj").args(["bookmark", "set", "main", "-r", "@-"]).current_dir(repo).output().unwrap();
let remote_path = remote_dir.path().to_string_lossy().to_string();
Command::new("jj").args(["git", "remote", "add", "origin", &remote_path]).current_dir(repo).output().unwrap();
Command::new("jj").args(["git", "push", "--bookmark", "main"]).current_dir(repo).output().unwrap();
let rev = resolve_base_rev(repo);
assert_eq!(rev, "trunk()", "should use trunk() when remote tracking bookmarks exist");
}
fn setup_repo_with_remote() -> (tempfile::TempDir, tempfile::TempDir) {
let remote_dir = tempfile::TempDir::new().unwrap();
Command::new("git").args(["init", "--bare"]).current_dir(remote_dir.path()).output().unwrap();
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("base.txt"), "trunk content\n").unwrap();
Command::new("jj").args(["commit", "-m", "base"]).current_dir(repo).output().unwrap();
Command::new("jj").args(["bookmark", "set", "main", "-r", "@-"]).current_dir(repo).output().unwrap();
let remote_path = remote_dir.path().to_string_lossy().to_string();
Command::new("jj").args(["git", "remote", "add", "origin", &remote_path]).current_dir(repo).output().unwrap();
Command::new("jj").args(["git", "push", "--bookmark", "main"]).current_dir(repo).output().unwrap();
(temp, remote_dir)
}
#[test]
fn test_jj_stack_coloring_two_commits() {
if !jj_available() { return; }
let (temp, _remote) = setup_repo_with_remote();
let repo = temp.path();
std::fs::write(repo.join("stack.txt"), "from earlier commit\n").unwrap();
Command::new("jj").args(["commit", "-m", "stack commit 1"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("stack.txt"), "from earlier commit\nfrom current commit\n").unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
assert_eq!(vcs.from_rev, "trunk()");
let cancel = Arc::new(AtomicBool::new(false));
let result = vcs.refresh(&cancel).unwrap();
let has_committed = result.lines.iter().any(|l| l.source == LineSource::Committed);
let has_staged = result.lines.iter().any(|l| l.source == LineSource::Staged);
assert!(has_committed, "earlier stack commit lines should be Committed (teal)");
assert!(has_staged, "current commit lines should be Staged (green)");
}
#[test]
fn test_jj_single_commit_above_trunk_all_staged() {
if !jj_available() { return; }
let (temp, _remote) = setup_repo_with_remote();
let repo = temp.path();
std::fs::write(repo.join("feature.txt"), "new feature\n").unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
assert_eq!(vcs.from_rev, "trunk()");
let cancel = Arc::new(AtomicBool::new(false));
let result = vcs.refresh(&cancel).unwrap();
assert!(!result.files.is_empty(), "should detect the new file");
let has_staged = result.lines.iter().any(|l| l.source == LineSource::Staged);
let has_committed = result.lines.iter().any(|l| l.source == LineSource::Committed);
assert!(has_staged, "single commit above trunk: all additions should be Staged");
assert!(!has_committed, "single commit above trunk: no lines should be Committed");
}
#[test]
fn test_resolve_stack_tip_at_tip() {
if !jj_available() { return; }
let (temp, _remote) = setup_repo_with_remote();
let repo = temp.path();
std::fs::write(repo.join("feature.txt"), "content\n").unwrap();
let tip = resolve_stack_tip(repo, "trunk()");
assert!(tip.is_none(), "should return None when @ is already the tip");
}
#[test]
fn test_resolve_stack_tip_mid_stack() {
if !jj_available() { return; }
let (temp, _remote) = setup_repo_with_remote();
let repo = temp.path();
std::fs::write(repo.join("file.txt"), "commit1\n").unwrap();
Command::new("jj").args(["commit", "-m", "commit1"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "commit2\n").unwrap();
Command::new("jj").args(["commit", "-m", "commit2"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "commit3\n").unwrap();
Command::new("jj").args(["commit", "-m", "commit3"]).current_dir(repo).output().unwrap();
Command::new("jj").args(["edit", "@---"]).current_dir(repo).output().unwrap();
let tip = resolve_stack_tip(repo, "trunk()");
assert!(tip.is_some(), "should return a tip when @ is mid-stack");
let tip = tip.unwrap();
assert_eq!(tip.head_count, 1, "linear stack should have 1 head");
assert!(!tip.change_id.is_empty());
}
#[test]
fn test_resolve_stack_tip_without_trunk() {
if !jj_available() { return; }
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
let tip = resolve_stack_tip(repo, "@-");
assert!(tip.is_none(), "should return None when from_rev is not trunk()");
}
#[test]
fn test_resolve_stack_tip_branching() {
if !jj_available() { return; }
let (temp, _remote) = setup_repo_with_remote();
let repo = temp.path();
std::fs::write(repo.join("file.txt"), "commit1\n").unwrap();
Command::new("jj").args(["commit", "-m", "commit1"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("branch_a.txt"), "branch a\n").unwrap();
Command::new("jj").args(["commit", "-m", "commit2a"]).current_dir(repo).output().unwrap();
Command::new("jj").args(["edit", "@--"]).current_dir(repo).output().unwrap();
Command::new("jj").args(["new"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("branch_b.txt"), "branch b\n").unwrap();
Command::new("jj").args(["commit", "-m", "commit2b"]).current_dir(repo).output().unwrap();
Command::new("jj").args(["edit", "@--"]).current_dir(repo).output().unwrap();
let tip = resolve_stack_tip(repo, "trunk()");
assert!(tip.is_some(), "should detect stack tip when @ is below a fork");
let tip = tip.unwrap();
assert_eq!(tip.head_count, 2, "branching stack should have 2 heads");
}
#[test]
fn test_stack_position_mid_stack() {
if !jj_available() { return; }
let (temp, _remote) = setup_repo_with_remote();
let repo = temp.path();
std::fs::write(repo.join("file.txt"), "commit1\n").unwrap();
Command::new("jj").args(["commit", "-m", "commit1"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "commit2\n").unwrap();
Command::new("jj").args(["commit", "-m", "commit2"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "commit3\n").unwrap();
Command::new("jj").args(["commit", "-m", "commit3"]).current_dir(repo).output().unwrap();
Command::new("jj").args(["edit", "@---"]).current_dir(repo).output().unwrap();
let tip = resolve_stack_tip(repo, "trunk()").expect("should have a tip");
let (current, total) = compute_stack_position(repo, &tip.change_id)
.expect("should compute position");
assert!(current >= 1 && current <= total,
"current ({current}) should be within 1..={total}");
assert!(total >= 3, "total ({total}) should be at least 3 commits");
}
#[test]
fn test_jj_midstack_coloring() {
if !jj_available() { return; }
let (temp, _remote) = setup_repo_with_remote();
let repo = temp.path();
std::fs::write(repo.join("file.txt"), "line1\n").unwrap();
Command::new("jj").args(["commit", "-m", "commit1"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "line1\nline2\n").unwrap();
Command::new("jj").args(["commit", "-m", "commit2"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("file.txt"), "line1\nline2\nline3\n").unwrap();
Command::new("jj").args(["commit", "-m", "commit3"]).current_dir(repo).output().unwrap();
Command::new("jj").args(["edit", "@--"]).current_dir(repo).output().unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
let cancel = Arc::new(AtomicBool::new(false));
let result = vcs.refresh(&cancel).unwrap();
let has_committed = result.lines.iter().any(|l| l.source == LineSource::Committed);
let has_staged = result.lines.iter().any(|l| l.source == LineSource::Staged);
let has_unstaged = result.lines.iter().any(|l| l.source == LineSource::Unstaged);
assert!(has_committed, "earlier stack commits should produce Committed (teal)");
assert!(has_staged, "current commit should produce Staged (green)");
assert!(has_unstaged, "later stack commits should produce Unstaged (yellow)");
assert!(result.stack_position.is_some(), "mid-stack should have stack_position");
let pos = result.stack_position.unwrap();
assert_eq!(pos.head_count, 1, "linear stack should have 1 head");
}
#[test]
fn test_jj_midstack_later_only_file() {
if !jj_available() { return; }
let (temp, _remote) = setup_repo_with_remote();
let repo = temp.path();
std::fs::write(repo.join("base.txt"), "base stuff\n").unwrap();
Command::new("jj").args(["commit", "-m", "commit1"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("current.txt"), "current\n").unwrap();
Command::new("jj").args(["commit", "-m", "commit2"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("later.txt"), "later only\n").unwrap();
Command::new("jj").args(["commit", "-m", "commit3"]).current_dir(repo).output().unwrap();
Command::new("jj").args(["edit", "@---"]).current_dir(repo).output().unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
let cancel = Arc::new(AtomicBool::new(false));
let result = vcs.refresh(&cancel).unwrap();
let later_file = result.files.iter().find(|f| {
f.lines.first().is_some_and(|l| l.content.contains("later.txt"))
});
assert!(later_file.is_some(), "later.txt should be in diff (file only in later commit)");
let later_lines: Vec<_> = later_file.unwrap().lines.iter()
.filter(|l| l.source == LineSource::Unstaged)
.collect();
assert!(!later_lines.is_empty(),
"later.txt content should be Unstaged (only changed in later commit)");
}
#[test]
fn test_jj_at_tip_no_stack_position() {
if !jj_available() { return; }
let (temp, _remote) = setup_repo_with_remote();
let repo = temp.path();
std::fs::write(repo.join("feature.txt"), "content\n").unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
let cancel = Arc::new(AtomicBool::new(false));
let result = vcs.refresh(&cancel).unwrap();
assert!(result.stack_position.is_none(),
"stack_position should be None when @ is at the tip");
}
#[test]
fn test_jj_trunk_deletion_coloring() {
if !jj_available() { return; }
let remote_dir = tempfile::TempDir::new().unwrap();
Command::new("git").args(["init", "--bare"]).current_dir(remote_dir.path()).output().unwrap();
let temp = tempfile::TempDir::new().unwrap();
let repo = temp.path();
Command::new("jj").args(["git", "init"]).current_dir(repo).output().unwrap();
std::fs::write(repo.join("doomed.txt"), "will be deleted\n").unwrap();
Command::new("jj").args(["commit", "-m", "base"]).current_dir(repo).output().unwrap();
Command::new("jj").args(["bookmark", "set", "main", "-r", "@-"]).current_dir(repo).output().unwrap();
let remote_path = remote_dir.path().to_string_lossy().to_string();
Command::new("jj").args(["git", "remote", "add", "origin", &remote_path]).current_dir(repo).output().unwrap();
Command::new("jj").args(["git", "push", "--bookmark", "main"]).current_dir(repo).output().unwrap();
std::fs::remove_file(repo.join("doomed.txt")).unwrap();
let vcs = JjVcs::new(repo.to_path_buf()).unwrap();
assert_eq!(vcs.from_rev, "trunk()");
let cancel = Arc::new(AtomicBool::new(false));
let result = vcs.refresh(&cancel).unwrap();
assert!(!result.files.is_empty(), "should detect deleted file");
let header = &result.lines[0];
assert!(header.content.contains("(deleted)"),
"deleted file should have deletion header, got: {}", header.content);
let has_deleted = result.lines.iter().any(|l| l.source == LineSource::DeletedCommitted);
assert!(has_deleted, "file deleted in current commit should have DeletedCommitted source");
}
#[test]
fn test_parse_rev_metadata_bookmark_with_shortest_id() {
let (id, label) = parse_rev_metadata("main\0knmqypts1234\0knmq");
assert_eq!(id, "knmqypts1234");
assert_eq!(label, "knmq (main)");
}
#[test]
fn test_parse_rev_metadata_no_bookmark() {
let (id, label) = parse_rev_metadata("\0knmqypts1234\0knmq");
assert_eq!(id, "knmqypts1234");
assert_eq!(label, "knmq", "no bookmark → shortest change_id");
}
#[test]
fn test_parse_rev_metadata_tracking_marker_with_shortest_id() {
let (id, label) = parse_rev_metadata("feat*\0abcd1234efgh\0abcd");
assert_eq!(id, "abcd1234efgh");
assert_eq!(label, "abcd (feat)");
}
#[test]
fn test_parse_rev_metadata_two_field_fallback() {
let (id, label) = parse_rev_metadata("main\0knmqypts1234");
assert_eq!(id, "knmqypts1234");
assert_eq!(label, "main", "2-field format falls back to bookmark only");
}
#[test]
fn test_parse_rev_metadata_multiple_bookmarks() {
let (id, label) = parse_rev_metadata("b1 b2\0xvryypywztmm\0xvry");
assert_eq!(id, "xvryypywztmm");
assert_eq!(label, "xvry (b1 b2)");
}
#[test]
fn test_parse_rev_metadata_no_delimiter() {
let (id, label) = parse_rev_metadata("rawvalue");
assert_eq!(id, "rawvalue");
assert_eq!(label, "rawvalue");
}
fn make_file_diff(lines: Vec<crate::diff::DiffLine>) -> crate::diff::FileDiff {
crate::diff::FileDiff::new(lines)
}
#[test]
fn test_mark_bookmark_provenance_file_in_bookmark() {
let lines = vec![
crate::diff::DiffLine::new(LineSource::Committed, "added".to_string(), '+', None),
crate::diff::DiffLine::new(LineSource::DeletedBase, "removed".to_string(), '-', None),
crate::diff::DiffLine::new(LineSource::Staged, "staged".to_string(), '+', None),
crate::diff::DiffLine::new(LineSource::Base, "context".to_string(), ' ', None),
];
let mut fd = make_file_diff(lines);
mark_bookmark_provenance(&mut fd, true);
assert_eq!(fd.lines[0].in_current_bookmark, Some(true),
"Committed lines in a bookmark file should be marked true");
assert_eq!(fd.lines[1].in_current_bookmark, Some(true),
"DeletedBase lines in a bookmark file should be marked true");
assert_eq!(fd.lines[2].in_current_bookmark, Some(true),
"Staged lines in a bookmark file should be marked true");
assert_eq!(fd.lines[3].in_current_bookmark, Some(false),
"Base context lines are never in current bookmark");
}
#[test]
fn test_mark_bookmark_provenance_file_not_in_bookmark() {
let lines = vec![
crate::diff::DiffLine::new(LineSource::Committed, "added".to_string(), '+', None),
crate::diff::DiffLine::new(LineSource::DeletedBase, "removed".to_string(), '-', None),
crate::diff::DiffLine::new(LineSource::Staged, "staged".to_string(), '+', None),
crate::diff::DiffLine::new(LineSource::Base, "context".to_string(), ' ', None),
];
let mut fd = make_file_diff(lines);
mark_bookmark_provenance(&mut fd, false);
assert_eq!(fd.lines[0].in_current_bookmark, Some(false),
"Committed lines NOT in bookmark file should be marked false");
assert_eq!(fd.lines[1].in_current_bookmark, Some(false),
"DeletedBase lines NOT in bookmark file should be marked false");
assert_eq!(fd.lines[2].in_current_bookmark, Some(false),
"Staged lines NOT in bookmark file should be marked false");
assert_eq!(fd.lines[3].in_current_bookmark, Some(false),
"Base context lines are never in current bookmark");
}
#[test]
fn test_mark_bookmark_provenance_base_with_change_source() {
let mut line = crate::diff::DiffLine::new(LineSource::Base, "modified".to_string(), ' ', None);
line.change_source = Some(LineSource::Committed);
let mut fd = make_file_diff(vec![line]);
mark_bookmark_provenance(&mut fd, true);
assert_eq!(fd.lines[0].in_current_bookmark, Some(true),
"Base with change_source in bookmark file should be marked true");
let mut line2 = crate::diff::DiffLine::new(LineSource::Base, "modified".to_string(), ' ', None);
line2.change_source = Some(LineSource::Committed);
let mut fd2 = make_file_diff(vec![line2]);
mark_bookmark_provenance(&mut fd2, false);
assert_eq!(fd2.lines[0].in_current_bookmark, Some(false),
"Base with change_source NOT in bookmark file should be marked false");
}
#[test]
fn test_boundary_revset_includes_remote_bookmarks() {
let change_id = "abc123def456";
let revset = format!(
"latest((trunk()..\"{}\"-) & (bookmarks() | remote_bookmarks()))",
change_id
);
assert!(revset.contains("remote_bookmarks()"),
"Boundary revset must include remote_bookmarks() for pushed bookmark segments");
assert!(revset.contains(&format!("\"{}\"", change_id)),
"Boundary revset must reference the current bookmark's change ID");
}
}