use std::collections::HashSet;
use std::path::Path;
use anyhow::{bail, Context, Result};
use vcs_runner::{
jj_revset_history, parse_diff_summary, run_git, run_git_utf8, run_jj, run_jj_utf8, run_jj_utf8_ignore_wc, RunError,
};
use super::{detect_git_remote, path_str, trunk, Vcs};
pub struct JjBackend;
impl JjBackend {
fn resolve_commit(&self, project_dir: &Path, revset: &str) -> Result<String> {
let out = run_jj_utf8(
project_dir,
&["log", "--ignore-working-copy", "--no-graph", "-r", revset, "-T", "commit_id ++ \"\\n\"", "--limit", "1"],
)?;
let id = out.lines().next().unwrap_or("").trim().to_string();
if id.is_empty() {
bail!("revision `{revset}` resolved to no commit");
}
Ok(id)
}
fn add_workspace(&self, project_dir: &Path, ws_dir: &Path, ws_id: &str, rev: &str) -> Result<()> {
let add = || self.run_workspace_add(project_dir, ws_dir, ws_id, rev);
match add() {
Ok(()) => Ok(()),
Err(e) if is_stale_working_copy(&e) => {
eprintln!("Main repo working copy is stale; recovering with `jj workspace update-stale`...");
self.cleanup_partial_workspace(ws_id, project_dir, ws_dir);
run_jj(project_dir, &["workspace", "update-stale"]).context("failed to refresh stale working copy")?;
add().map_err(|e2| {
self.cleanup_partial_workspace(ws_id, project_dir, ws_dir);
anyhow::Error::new(e2).context("failed to create jj workspace after update-stale")
})
}
Err(e) => {
self.cleanup_partial_workspace(ws_id, project_dir, ws_dir);
Err(anyhow::Error::new(e).context("failed to create jj workspace"))
}
}
}
fn run_workspace_add(&self, project_dir: &Path, ws_dir: &Path, ws_id: &str, rev: &str) -> Result<(), RunError> {
let args = ["workspace", "add", "--config", "git.colocate=false", &path_str(ws_dir)];
run_jj(project_dir, &[&args[..], &["--name", ws_id, "-r", rev]].concat()).map(|_| ())
}
fn cleanup_partial_workspace(&self, ws_id: &str, project_dir: &Path, ws_dir: &Path) {
let _ = run_jj(project_dir, &["workspace", "forget", ws_id]);
if let Some(git_dir) = absolute_git_dir(project_dir) {
let _ = std::fs::remove_dir_all(format!("{git_dir}/worktrees/{ws_id}"));
}
let _ = std::fs::remove_dir_all(ws_dir);
}
fn revset_nonempty(&self, project_dir: &Path, revset: &str) -> bool {
run_jj_utf8(project_dir, &["log", "--ignore-working-copy", "--no-graph", "-r", revset, "-T", r#""x""#])
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
}
fn repo_orphans(&self, project_dir: &Path) -> Vec<String> {
let revset = "~empty() & ~ancestors(bookmarks() | remote_bookmarks()) \
& ~working_copies() & ~ancestors(working_copies())";
run_jj_utf8_ignore_wc(project_dir, &["log", "--no-graph", "-r", revset, "-T", r#"commit_id ++ "\n""#])
.map(|s| s.lines().filter(|l| !l.trim().is_empty()).map(String::from).collect())
.unwrap_or_default()
}
fn abandoned_by_workspace(&self, ws_id: &str, project_dir: &Path, orphans: &[String]) -> Vec<String> {
let want: HashSet<&str> = orphans.iter().map(String::as_str).collect();
jj_revset_history(project_dir, &format!("{ws_id}@"), 0)
.unwrap_or_default()
.into_iter()
.filter(|cid| want.contains(cid.as_str()))
.collect()
}
}
pub(crate) fn init_jj(project_dir: &Path) -> Result<()> {
eprintln!("Initializing jj colocated repo in {}...", project_dir.display());
run_jj(project_dir, &["git", "init", "--colocate"])?;
let (main_branch, remote) = detect_trunk_git(project_dir);
run_jj(project_dir, &["bookmark", "track", &format!("{main_branch}@{remote}")])?;
let auto_track_key = format!("remotes.{remote}.auto-track-bookmarks");
run_jj(project_dir, &["config", "set", "--repo", &auto_track_key, "glob:*"])?;
eprintln!("jj initialized, tracking {main_branch}@{remote}");
Ok(())
}
pub(crate) fn first_real_bookmark(raw: &str) -> &str {
raw.split_whitespace()
.map(|b| b.trim_end_matches(['*', '?']))
.find(|b| !b.is_empty() && !b.ends_with("@git"))
.unwrap_or("")
}
fn detect_trunk_git(project_dir: &Path) -> (String, String) {
let remote = detect_git_remote(project_dir);
let has_master = run_git(project_dir, &["rev-parse", "--verify", &format!("{remote}/master")]).is_ok();
let branch = if has_master { "master" } else { "main" };
(branch.into(), remote)
}
fn remote_scoped(name: &str) -> String {
format!(r#"remote_bookmarks(exact:"{name}", remote=exact:"origin")"#)
}
pub(super) fn jj_trunk_bookmark(project_dir: &Path, revset: &str) -> Option<String> {
let output =
run_jj_utf8(project_dir, &["log", "-r", revset, "--no-graph", "-T", "bookmarks", "--limit", "1"]).ok()?;
let bookmark = first_real_bookmark(&output);
(!bookmark.is_empty()).then(|| bookmark.to_string())
}
impl Vcs for JjBackend {
fn detect_trunk(&self, project_dir: &Path) -> Result<String> {
let origin_revsets =
["trunk()".to_string(), remote_scoped("main"), remote_scoped("master"), remote_scoped("trunk")];
for revset in &origin_revsets {
if let Some(bookmark) = jj_trunk_bookmark(project_dir, revset) {
return Ok(bookmark);
}
}
let (branch, remote) = detect_trunk_git(project_dir);
if let Ok(cid) = run_git_utf8(project_dir, &["rev-parse", "--verify", &format!("{remote}/{branch}")]) {
let cid = cid.trim();
if !cid.is_empty() {
return Ok(cid.to_string());
}
}
for revset in [r#"latest(remote_bookmarks(exact:"master"))"#, r#"latest(remote_bookmarks(exact:"main"))"#] {
if let Some(bookmark) = jj_trunk_bookmark(project_dir, revset) {
return Ok(bookmark);
}
}
Ok(trunk::local_trunk_bookmark(project_dir).unwrap_or_else(|| "main".into()))
}
fn resolve_base(&self, project_dir: &Path, rev: &str) -> Result<String> {
trunk::resolve_single(project_dir, rev)
}
fn create_workspace(&self, project_dir: &Path, ws_dir: &Path, ws_id: &str, trunk: &str) -> Result<String> {
eprintln!("Creating jj workspace {ws_id}...");
let base = self
.resolve_commit(project_dir, trunk)
.with_context(|| trunk::unresolved_trunk(trunk, trunk::repo_has_commits(project_dir)))?;
self.add_workspace(project_dir, ws_dir, ws_id, &base)?;
if let Err(e) = setup_git_worktree(project_dir, ws_dir, ws_id, &base) {
eprintln!("Warning: could not set up git worktree for workspace: {e}");
}
Ok(base)
}
fn pre_copy_sync(&self, project_dir: &Path) {
let _ = run_jj(project_dir, &["status"]);
}
fn changed_files(&self, ws_id: &str, base: &str, project_dir: &Path, _ws_dir: &Path) -> Vec<String> {
let ws_head = format!("{ws_id}@");
let unsaved = format!("({base}..{ws_head}) & ~empty() & ~ancestors(bookmarks() | remote_bookmarks())");
if !self.revset_nonempty(project_dir, &unsaved) {
return Vec::new();
}
run_jj_utf8(project_dir, &["diff", "--ignore-working-copy", "--from", base, "--to", &ws_head, "--summary"])
.map(|stdout| {
parse_diff_summary(&stdout).into_iter().map(|c| c.path.to_string_lossy().into_owned()).collect()
})
.unwrap_or_default()
}
fn save_work(&self, ws_id: &str, base: &str, project_dir: &Path, _ws_dir: &Path) -> Result<()> {
let ws_head = format!("{ws_id}@");
let target = run_jj_utf8(
project_dir,
&[
"log",
"--ignore-working-copy",
"--no-graph",
"-T",
"commit_id ++ \"\\n\"",
"-r",
&format!("heads(({base}..{ws_head}) & ~empty())"),
],
)
.ok()
.and_then(|s| s.lines().next().map(str::to_string))
.filter(|s| !s.is_empty())
.unwrap_or(ws_head);
run_jj(project_dir, &["bookmark", "set", &format!("workon/{ws_id}"), "-r", &target])?;
eprintln!("Bookmarked as workon/{ws_id}");
Ok(())
}
fn stranded_work(&self, ws_id: &str, _base: &str, project_dir: &Path, _ws_dir: &Path) -> Vec<String> {
let orphans = self.repo_orphans(project_dir);
if orphans.is_empty() {
return Vec::new();
}
let mine = self.abandoned_by_workspace(ws_id, project_dir, &orphans);
if mine.is_empty() {
return Vec::new();
}
run_jj_utf8(
project_dir,
&["log", "--ignore-working-copy", "--no-graph", "-r", &mine.join("|"),
"-T", r#"commit_id.shortest(8) ++ " " ++ if(description, description.first_line(), "(no description set)") ++ "\n""#],
)
.map(|s| s.lines().filter(|l| !l.trim().is_empty()).map(String::from).collect())
.unwrap_or_default()
}
fn save_stranded(&self, project_dir: &Path, ws_id: &str, commit_id: &str) -> Result<()> {
let name = format!("workon/{ws_id}-{commit_id}");
run_jj(project_dir, &["bookmark", "set", &name, "-r", commit_id])?;
eprintln!("Saved stranded commit {commit_id} as {name}");
Ok(())
}
fn forget_workspace(&self, _ws_id: &str, project_dir: &Path, ws_dir: &Path) -> Result<()> {
let Some(name) = crate::registered::name_to_forget(project_dir, ws_dir)? else { return Ok(()) };
run_jj(project_dir, &["workspace", "forget", &name])?;
if let Some(git_dir) = absolute_git_dir(project_dir) {
let wt_dir = format!("{git_dir}/worktrees/{name}");
let _ = std::fs::remove_dir_all(wt_dir);
}
eprintln!("Forgot jj workspace {name}");
Ok(())
}
fn ignore_generated_file(&self, project_dir: &Path, ws_dir: &Path, relpath: &str) {
if let Err(e) = super::append_git_exclude(project_dir, relpath) {
eprintln!("Warning: could not exclude {relpath} from git: {e}");
return;
}
if let Err(e) = run_jj(ws_dir, &["file", "untrack", relpath]) {
eprintln!("Warning: could not untrack {relpath} in jj: {e}");
}
}
}
fn is_stale_working_copy(err: &RunError) -> bool {
err.stderr().is_some_and(stderr_reports_stale)
}
fn stderr_reports_stale(stderr: &str) -> bool {
stderr.contains("working copy is stale")
}
fn absolute_git_dir(project_dir: &Path) -> Option<String> {
run_git_utf8(project_dir, &["rev-parse", "--absolute-git-dir"]).ok().filter(|s| !s.is_empty())
}
fn setup_git_worktree(project_dir: &Path, ws_dir: &Path, ws_id: &str, base: &str) -> Result<()> {
let git_dir = absolute_git_dir(project_dir).context("could not determine .git directory")?;
let wt_git_dir = format!("{git_dir}/worktrees/{ws_id}");
std::fs::create_dir_all(&wt_git_dir)?;
std::fs::write(format!("{wt_git_dir}/gitdir"), format!("{}/.git\n", path_str(ws_dir)))?;
std::fs::write(format!("{wt_git_dir}/commondir"), "../..\n")?;
std::fs::write(format!("{wt_git_dir}/HEAD"), format!("{base}\n"))?;
std::fs::write(ws_dir.join(".git"), format!("gitdir: {wt_git_dir}\n"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::process::{Command, Stdio};
use super::*;
#[test]
fn first_real_bookmark_picks_non_git_entry() {
assert_eq!(first_real_bookmark("master@heroku master@git"), "master@heroku");
}
#[test]
fn first_real_bookmark_returns_bare_name() {
assert_eq!(first_real_bookmark("main"), "main");
}
#[test]
fn first_real_bookmark_skips_git_only() {
assert_eq!(first_real_bookmark("main@git"), "");
}
#[test]
fn first_real_bookmark_empty_input() {
assert_eq!(first_real_bookmark(""), "");
assert_eq!(first_real_bookmark(" "), "");
}
#[test]
fn first_real_bookmark_strips_sync_markers() {
assert_eq!(first_real_bookmark("master*"), "master");
assert_eq!(first_real_bookmark("master?"), "master");
assert_eq!(first_real_bookmark("master* master@heroku_test master@git"), "master");
assert_eq!(first_real_bookmark("master*?"), "master");
}
fn git(repo: &Path, args: &[&str]) {
let repo_path = path_str(repo);
let mut full = vec!["-C", &*repo_path];
full.extend_from_slice(args);
Command::new("git").args(&full).stdout(Stdio::null()).stderr(Stdio::null()).status().unwrap();
}
#[test]
fn ignore_generated_file_drops_phantom_add_in_jj_workspace() {
if !vcs_runner::jj_available() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "--initial-branch=main"]);
git(&repo, &["config", "user.email", "t@t.com"]);
git(&repo, &["config", "user.name", "T"]);
std::fs::write(repo.join("README"), "hi").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "init"]);
run_jj(&repo, &["git", "init", "--colocate"]).unwrap();
run_jj(&repo, &["workspace", "add", &path_str(&ws), "--name", "testws", "-r", "main"]).unwrap();
std::fs::write(ws.join(".env.test.local"), "DATABASE_URL=postgresql://localhost/x").unwrap();
let before = run_jj_utf8(&ws, &["status"]).unwrap();
assert!(before.contains(".env.test.local"), "jj should track the file initially");
JjBackend.ignore_generated_file(&repo, &ws, ".env.test.local");
let after = run_jj_utf8(&ws, &["status"]).unwrap();
assert!(
!after.contains(".env.test.local"),
"excluded + untracked file should not surface in jj status, got:\n{after}"
);
}
fn jj_repo(tmp: &Path) -> Option<std::path::PathBuf> {
if !vcs_runner::jj_available() {
return None;
}
let repo = tmp.join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "--initial-branch=main"]);
git(&repo, &["config", "user.email", "t@t.com"]);
git(&repo, &["config", "user.name", "T"]);
std::fs::write(repo.join("README"), "hi").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "init"]);
run_jj(&repo, &["git", "init", "--colocate"]).unwrap();
Some(repo)
}
fn workspace_names(repo: &Path) -> String {
run_jj_utf8(repo, &["workspace", "list", "--ignore-working-copy"]).unwrap()
}
#[test]
fn add_workspace_recovers_from_a_stale_main_working_copy() {
let tmp = tempfile::tempdir().unwrap();
let Some(repo) = jj_repo(tmp.path()) else { return };
let other = tmp.path().join("other");
run_jj(&repo, &["workspace", "add", &path_str(&other), "--name", "other", "-r", "main"]).unwrap();
let before =
run_jj_utf8(&repo, &["op", "log", "--ignore-working-copy", "--no-graph", "-n1", "-T", "id.short()"])
.unwrap();
std::fs::write(repo.join("notes"), "work").unwrap();
run_jj(&repo, &["status"]).unwrap();
run_jj(&other, &["op", "restore", before.trim()]).unwrap();
assert!(run_jj(&repo, &["status"]).is_err(), "the main working copy is stale");
let ws = tmp.path().join("ws");
JjBackend.add_workspace(&repo, &ws, "ws-stale", "main").unwrap();
assert!(ws.is_dir());
assert!(workspace_names(&repo).contains("ws-stale"), "{}", workspace_names(&repo));
}
#[test]
fn add_workspace_reports_another_failure_without_refreshing() {
let tmp = tempfile::tempdir().unwrap();
let Some(repo) = jj_repo(tmp.path()) else { return };
let err = JjBackend.add_workspace(&repo, &tmp.path().join("ws"), "ws-bad", "no-such-rev").unwrap_err();
assert_eq!(err.to_string(), "failed to create jj workspace", "{err:#}");
assert!(!workspace_names(&repo).contains("ws-bad"));
}
fn git_repo_with_remote(tmp: &Path, remote: &str, branch: &str) -> std::path::PathBuf {
let repo = tmp.join("repo");
let bare = tmp.join("bare.git");
std::fs::create_dir_all(&repo).unwrap();
git(tmp, &["init", "--bare", "--initial-branch", branch, &path_str(&bare)]);
git(&repo, &["init", "--initial-branch", branch]);
git(&repo, &["config", "user.email", "t@t.com"]);
git(&repo, &["config", "user.name", "T"]);
git(&repo, &["commit", "--allow-empty", "-m", "init"]);
git(&repo, &["remote", "add", remote, &path_str(&bare)]);
git(&repo, &["push", remote, branch]);
git(&repo, &["fetch", remote]);
repo
}
#[test]
fn detect_trunk_git_names_the_branch_and_the_only_remote() {
let tmp = tempfile::tempdir().unwrap();
let repo = git_repo_with_remote(tmp.path(), "upstream", "master");
assert_eq!(detect_trunk_git(&repo), ("master".to_string(), "upstream".to_string()));
let tmp = tempfile::tempdir().unwrap();
let repo = git_repo_with_remote(tmp.path(), "origin", "main");
assert_eq!(detect_trunk_git(&repo), ("main".to_string(), "origin".to_string()), "no master falls back to main");
}
#[test]
fn init_jj_tracks_the_trunk_and_auto_tracks_the_remote() {
if !vcs_runner::jj_available() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let repo = git_repo_with_remote(tmp.path(), "origin", "main");
init_jj(&repo).unwrap();
assert!(repo.join(".jj").is_dir());
let tracked = run_jj_utf8(
&repo,
&["bookmark", "list", "--tracked", "--ignore-working-copy", "-T", r#"name ++ "@" ++ remote ++ "\n""#],
)
.unwrap();
assert!(tracked.lines().any(|l| l == "main@origin"), "{tracked}");
let auto =
run_jj_utf8(&repo, &["config", "get", "--ignore-working-copy", "remotes.origin.auto-track-bookmarks"])
.unwrap();
assert_eq!(auto.trim(), "glob:*");
}
#[test]
fn detect_trunk_falls_back_to_the_commit_git_names() {
let tmp = tempfile::tempdir().unwrap();
let Some(repo) = jj_repo(tmp.path()) else { return };
git(&repo, &["branch", "origin/master"]);
let commit = run_git_utf8(&repo, &["rev-parse", "origin/master"]).unwrap();
assert_eq!(JjBackend.detect_trunk(&repo).unwrap(), commit.trim());
}
#[test]
fn pre_copy_sync_snapshots_the_working_copy() {
let tmp = tempfile::tempdir().unwrap();
let Some(repo) = jj_repo(tmp.path()) else { return };
std::fs::write(repo.join("new.txt"), "x").unwrap();
let empty =
|| run_jj_utf8(&repo, &["log", "--ignore-working-copy", "--no-graph", "-r", "@", "-T", "empty"]).unwrap();
assert_eq!(empty(), "true", "not snapshotted yet");
JjBackend.pre_copy_sync(&repo);
assert_eq!(empty(), "false");
}
#[test]
fn forget_workspace_forgets_it_and_drops_its_git_worktree_entry() {
let tmp = tempfile::tempdir().unwrap();
let Some(repo) = jj_repo(tmp.path()) else { return };
let ws = tmp.path().join("ws");
run_jj(&repo, &["workspace", "add", &path_str(&ws), "--name", "ws-gone", "-r", "main"]).unwrap();
let git_entry = repo.join(".git/worktrees/ws-gone");
std::fs::create_dir_all(&git_entry).unwrap();
JjBackend.forget_workspace("ws-gone", &repo, &ws).unwrap();
assert!(!workspace_names(&repo).contains("ws-gone"), "{}", workspace_names(&repo));
assert!(!git_entry.exists());
}
#[test]
fn cleanup_partial_workspace_forgets_it_and_removes_its_directory() {
let tmp = tempfile::tempdir().unwrap();
let Some(repo) = jj_repo(tmp.path()) else { return };
let ws = tmp.path().join("ws");
run_jj(&repo, &["workspace", "add", &path_str(&ws), "--name", "ws-half", "-r", "main"]).unwrap();
assert!(workspace_names(&repo).contains("ws-half"));
JjBackend.cleanup_partial_workspace("ws-half", &repo, &ws);
assert!(!workspace_names(&repo).contains("ws-half"), "{}", workspace_names(&repo));
assert!(!ws.exists());
}
#[test]
fn setup_git_worktree_enables_git_commands() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
Command::new("git")
.args(["init", "--initial-branch=main", &path_str(&project)])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
Command::new("git")
.args(["-C", &path_str(&project), "config", "user.email", "t@t.com"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
Command::new("git")
.args(["-C", &path_str(&project), "config", "user.name", "T"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
std::fs::write(project.join("README"), "hi").unwrap();
Command::new("git")
.args(["-C", &path_str(&project), "add", "."])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
Command::new("git")
.args(["-C", &path_str(&project), "commit", "-m", "init"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
let base = run_git_utf8(&project, &["rev-parse", "HEAD"]).unwrap().trim().to_string();
setup_git_worktree(&project, &ws, "test-ws", &base).unwrap();
assert!(ws.join(".git").is_file(), ".git file should exist in workspace");
let log = Command::new("git")
.args(["-C", &path_str(&ws), "log", "--oneline", "-1"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.unwrap();
assert!(log.status.success(), "git log should work in workspace");
let output = String::from_utf8_lossy(&log.stdout);
assert!(output.contains("init"), "should see the commit");
}
#[test]
fn setup_git_worktree_pins_head_to_base_not_a_remote_ref() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
Command::new("git")
.args(["init", "--initial-branch=main", &path_str(&project)])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
git(&project, &["config", "user.email", "t@t.com"]);
git(&project, &["config", "user.name", "T"]);
std::fs::write(project.join("README"), "hi").unwrap();
git(&project, &["add", "."]);
git(&project, &["commit", "-m", "init"]);
git(&project, &["update-ref", "refs/remotes/heroku/master", "HEAD"]);
std::fs::write(project.join("README"), "real master moved on").unwrap();
git(&project, &["commit", "-am", "advance real master"]);
let base = run_git_utf8(&project, &["rev-parse", "HEAD"]).unwrap().trim().to_string();
setup_git_worktree(&project, &ws, "ws-pin", &base).unwrap();
let git_dir = run_git_utf8(&project, &["rev-parse", "--absolute-git-dir"]).unwrap();
let head = std::fs::read_to_string(format!("{}/worktrees/ws-pin/HEAD", git_dir.trim())).unwrap();
assert_eq!(head.trim(), base, "worktree HEAD must equal the pinned base commit");
}
fn setup_ws(tmp: &std::path::Path, ws_id: &str) -> (std::path::PathBuf, std::path::PathBuf) {
let repo = tmp.join("repo");
let origin = tmp.join("origin.git");
let ws = tmp.join(format!("ws-{ws_id}"));
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "--initial-branch=master"]);
git(&repo, &["config", "user.email", "t@t.com"]);
git(&repo, &["config", "user.name", "T"]);
std::fs::write(repo.join("app.rb"), "v1").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "init"]);
Command::new("git")
.args(["clone", "--bare", &path_str(&repo), &path_str(&origin)])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
git(&repo, &["remote", "add", "origin", &path_str(&origin)]);
git(&repo, &["push", "origin", "master"]);
run_jj(&repo, &["git", "init", "--colocate"]).unwrap();
run_jj(&repo, &["bookmark", "track", "master@origin"]).unwrap();
run_jj(&repo, &["workspace", "add", &path_str(&ws), "--name", ws_id, "-r", "master"]).unwrap();
(repo, ws)
}
#[test]
fn detect_trunk_prefers_origin_over_newer_deploy_mirror() {
if !vcs_runner::jj_available() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let (repo, _ws) = setup_ws(tmp.path(), "wtrunk");
let heroku = tmp.path().join("heroku.git");
Command::new("git")
.args(["init", "--bare", "--initial-branch=main", &path_str(&heroku)])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
git(&repo, &["checkout", "-b", "main"]);
std::fs::write(repo.join("app.rb"), "mirror is newer").unwrap();
git(&repo, &["commit", "-am", "newer on mirror main"]);
git(&repo, &["remote", "add", "heroku", &path_str(&heroku)]);
git(&repo, &["push", "heroku", "main"]);
git(&repo, &["checkout", "master"]);
run_jj(&repo, &["git", "fetch", "--remote", "heroku"]).unwrap();
run_jj(&repo, &["config", "set", "--repo", r#"revset-aliases."trunk()""#, "none()"]).unwrap();
let trunk = JjBackend.detect_trunk(&repo).unwrap();
assert_eq!(trunk, "master", "must pick origin's master, not the newer heroku main, got {trunk:?}");
}
fn jj_base(repo: &Path) -> String {
run_jj_utf8(
repo,
&["log", "--ignore-working-copy", "--no-graph", "-r", "master", "-T", "commit_id", "--limit", "1"],
)
.unwrap()
.trim()
.to_string()
}
#[test]
fn changed_files_detects_committed_work() {
if !vcs_runner::jj_available() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let (repo, ws) = setup_ws(tmp.path(), "wsa");
let base = jj_base(&repo);
std::fs::write(ws.join("app.rb"), "agent change").unwrap();
run_jj(&ws, &["commit", "-m", "implement feature"]).unwrap();
let changed = JjBackend.changed_files("wsa", &base, &repo, &ws);
assert!(changed.contains(&"app.rb".to_string()), "should detect committed work, got {changed:?}");
}
#[test]
fn save_work_bookmarks_committed_stack_tip() {
if !vcs_runner::jj_available() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let (repo, ws) = setup_ws(tmp.path(), "wsb");
let base = jj_base(&repo);
std::fs::write(ws.join("app.rb"), "agent change").unwrap();
run_jj(&ws, &["commit", "-m", "implement feature"]).unwrap();
JjBackend.save_work("wsb", &base, &repo, &ws).unwrap();
let out = run_jj_utf8(
&repo,
&[
"log",
"--ignore-working-copy",
"--no-graph",
"-r",
"workon/wsb",
"-T",
r#"description.first_line() ++ "|" ++ if(empty, "empty", "nonempty")"#,
],
)
.unwrap();
assert!(out.contains("implement feature"), "bookmark should sit on the work commit, got {out}");
assert!(out.contains("nonempty"), "bookmark target must be non-empty, got {out}");
}
#[test]
fn changed_files_silent_for_bookmarked_work() {
if !vcs_runner::jj_available() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let (repo, ws) = setup_ws(tmp.path(), "wse");
let base = jj_base(&repo);
std::fs::write(ws.join("app.rb"), "agent change").unwrap();
run_jj(&ws, &["commit", "-m", "finish the PR"]).unwrap();
run_jj(&ws, &["bookmark", "set", "my-feature", "-r", "@-"]).unwrap();
assert!(
JjBackend.changed_files("wse", &base, &repo, &ws).is_empty(),
"bookmarked work is already saved and must not prompt"
);
}
#[test]
fn stranded_work_attributes_per_workspace() {
if !vcs_runner::jj_available() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let (repo, ws1) = setup_ws(tmp.path(), "wsone");
let ws2 = tmp.path().join("ws-two");
run_jj(&repo, &["workspace", "add", &path_str(&ws2), "--name", "wstwo", "-r", "master"]).unwrap();
std::fs::write(ws1.join("app.rb"), "ws1 edit").unwrap();
run_jj(&ws1, &["new", "master", "-m", "ws1 fresh"]).unwrap();
std::fs::write(ws2.join("app.rb"), "ws2 edit").unwrap();
run_jj(&ws2, &["new", "master", "-m", "ws2 fresh"]).unwrap();
let base = jj_base(&repo);
let s1 = JjBackend.stranded_work("wsone", &base, &repo, &ws1);
let s2 = JjBackend.stranded_work("wstwo", &base, &repo, &ws2);
assert_eq!(s1.len(), 1, "wsone should see only its own orphan, got {s1:?}");
assert_eq!(s2.len(), 1, "wstwo should see only its own orphan, got {s2:?}");
assert_ne!(
s1[0].split_whitespace().next(),
s2[0].split_whitespace().next(),
"the two workspaces must not be attributed the same commit"
);
}
#[test]
fn stranded_work_empty_for_clean_and_excludes_saved() {
if !vcs_runner::jj_available() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let (repo, ws) = setup_ws(tmp.path(), "wsx");
let base = jj_base(&repo);
assert!(JjBackend.stranded_work("wsx", &base, &repo, &ws).is_empty(), "clean workspace strands nothing");
std::fs::write(ws.join("app.rb"), "edit").unwrap();
run_jj(&ws, &["new", "master", "-m", "fresh"]).unwrap();
let s = JjBackend.stranded_work("wsx", &base, &repo, &ws);
assert_eq!(s.len(), 1, "the jj-new orphan should be attributed, got {s:?}");
let id = s[0].split_whitespace().next().unwrap().to_string();
JjBackend.save_stranded(&repo, "wsx", &id).unwrap();
assert!(
JjBackend.stranded_work("wsx", &base, &repo, &ws).is_empty(),
"a saved (bookmarked) stranded commit is no longer stranded"
);
}
#[test]
fn changed_files_uses_pinned_base_not_moved_trunk() {
if !vcs_runner::jj_available() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let (repo, _other) = setup_ws(tmp.path(), "wsother");
let ws = tmp.path().join("ws-pin");
let base = JjBackend.create_workspace(&repo, &ws, "wspin", "master").unwrap();
std::fs::write(repo.join("upstream.rb"), "upstream change").unwrap();
run_jj(&repo, &["commit", "-m", "upstream work"]).unwrap();
run_jj(&repo, &["bookmark", "set", "master", "-r", "@-"]).unwrap();
let moved_trunk = jj_base(&repo);
assert_ne!(base, moved_trunk, "precondition: trunk moved off the branch point");
run_jj(&ws, &["workspace", "update-stale"]).unwrap();
std::fs::write(ws.join("app.rb"), "agent change").unwrap();
run_jj(&ws, &["commit", "-m", "agent work"]).unwrap();
let pinned = JjBackend.changed_files("wspin", &base, &repo, &ws);
assert!(pinned.contains(&"app.rb".to_string()), "own work must be reported, got {pinned:?}");
assert!(
!pinned.contains(&"upstream.rb".to_string()),
"pinned base must not leak upstream changes, got {pinned:?}"
);
let leaked = JjBackend.changed_files("wspin", &moved_trunk, &repo, &ws);
assert!(
leaked.contains(&"upstream.rb".to_string()),
"sanity: a moved-trunk base leaks upstream.rb (the bug), got {leaked:?}"
);
}
#[test]
fn create_workspace_registers_one_git_worktree_pinned_at_the_base() {
if !vcs_runner::jj_available() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "--initial-branch=main"]);
git(&repo, &["config", "user.email", "t@t.com"]);
git(&repo, &["config", "user.name", "T"]);
std::fs::write(repo.join("README"), "hi").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "init"]);
run_jj(&repo, &["git", "init", "--colocate"]).unwrap();
let ws = tmp.path().join("ws-head");
let base = JjBackend.create_workspace(&repo, &ws, "wshead", "main").unwrap();
let listing = run_git_utf8(&repo, &["worktree", "list", "--porcelain"]).unwrap();
let worktrees = listing.lines().filter(|l| l.starts_with("worktree ")).count();
assert_eq!(worktrees, 2, "the repo and one workspace, got:\n{listing}");
let head = run_git_utf8(&ws, &["rev-parse", "HEAD"]).unwrap();
assert_eq!(head.trim(), base, "git HEAD must be pinned at the base");
}
#[test]
fn create_workspace_errors_helpfully_without_commits() {
if !vcs_runner::jj_available() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("empty");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "--initial-branch=main"]);
run_jj(&repo, &["git", "init", "--colocate"]).unwrap();
let ws = tmp.path().join("ws-empty");
let err = JjBackend.create_workspace(&repo, &ws, "wsempty", "main").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no commits"), "expected a no-commits hint, got: {msg}");
assert!(!ws.exists(), "no workspace dir should be left behind, got one at {ws:?}");
}
#[test]
fn stderr_reports_stale_matches_jj_message() {
assert!(stderr_reports_stale(
"Error: The working copy is stale (not updated since operation cd3e17046956).\n\
Hint: Run `jj workspace update-stale` to update it."
));
assert!(!stderr_reports_stale("Error: Revision `main` doesn't exist"));
assert!(!stderr_reports_stale(""));
}
fn setup_colocated(tmp: &Path) -> std::path::PathBuf {
let repo = tmp.join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "--initial-branch=main"]);
git(&repo, &["config", "user.email", "t@t.com"]);
git(&repo, &["config", "user.name", "T"]);
std::fs::write(repo.join("f.txt"), "a").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "init"]);
run_jj(&repo, &["git", "init", "--colocate"]).unwrap();
run_jj(&repo, &["describe", "-m", "A"]).unwrap();
run_jj(&repo, &["new", "-m", "B"]).unwrap();
repo
}
fn force_op_divergence(repo: &Path) {
let ops = run_jj_utf8(repo, &["op", "log", "--no-graph", "-T", "id ++ \"\\n\""]).unwrap();
let earlier = ops.lines().nth(2).expect("an older op to fork at").to_string();
run_jj(repo, &["--at-operation", &earlier, "describe", "-m", "FORKED"]).unwrap();
let _ = run_jj(repo, &["status"]); }
#[test]
fn divergence_read_detects_fork_without_snapshotting_wip() {
if !vcs_runner::jj_available() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let repo = setup_colocated(tmp.path());
assert!(vcs_runner::jj_divergent_change_ids(&repo).unwrap().is_empty(), "a clean repo reports no divergence");
force_op_divergence(&repo);
assert!(
!vcs_runner::jj_divergent_change_ids(&repo).unwrap().is_empty(),
"a reconciled op-log fork must surface as divergent"
);
std::fs::write(repo.join("f.txt"), "uncommitted WIP").unwrap();
let at = || {
run_jj_utf8(
&repo,
&["log", "--ignore-working-copy", "--no-graph", "-r", "@", "-T", "commit_id", "--limit", "1"],
)
.unwrap()
.trim()
.to_string()
};
let before = at();
let _ = vcs_runner::jj_divergent_change_ids(&repo);
assert_eq!(before, at(), "the divergence read must not snapshot the working copy (@ must not move)");
}
}