use std::path::Path;
use anyhow::{Context, Result};
use vcs_runner::{parse_diff_summary, run_git, run_git_utf8, run_jj, run_jj_utf8};
use super::{detect_git_remote, path_str, Vcs};
pub struct JjBackend;
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(())
}
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)
}
impl Vcs for JjBackend {
fn detect_trunk(&self, project_dir: &Path) -> Result<String> {
let revsets = [
"trunk()",
r#"latest(remote_bookmarks("master") | remote_bookmarks("main"))"#,
];
for revset in &revsets {
if let Ok(output) = run_jj_utf8(
project_dir,
&["log", "-r", revset, "--no-graph", "-T", "bookmarks", "--limit", "1"],
) {
let bookmark = first_real_bookmark(&output);
if !bookmark.is_empty() {
return Ok(bookmark.to_string());
}
}
}
Ok("main".into())
}
fn create_workspace(&self, project_dir: &Path, ws_dir: &Path, ws_id: &str, trunk: &str) -> Result<()> {
eprintln!("Creating jj workspace {ws_id}...");
run_jj(
project_dir,
&["workspace", "add", &path_str(ws_dir), "--name", ws_id, "-r", trunk],
)
.context("failed to create jj workspace")?;
if let Err(e) = setup_git_worktree(project_dir, ws_dir, ws_id, trunk) {
eprintln!("Warning: could not set up git worktree for workspace: {e}");
}
Ok(())
}
fn pre_copy_sync(&self, project_dir: &Path) {
let _ = run_jj(project_dir, &["status"]);
}
fn changed_files(&self, ws_id: &str, project_dir: &Path, _ws_dir: &Path) -> Vec<String> {
run_jj_utf8(
project_dir,
&["diff", "--ignore-working-copy", "-r", &format!("{ws_id}@"), "--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, project_dir: &Path, _ws_dir: &Path) -> Result<()> {
run_jj(
project_dir,
&["bookmark", "set", &format!("workon/{ws_id}"), "-r", &format!("{ws_id}@")],
)?;
eprintln!("Bookmarked as workon/{ws_id}");
Ok(())
}
fn forget_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 wt_dir = format!("{git_dir}/worktrees/{ws_id}");
let _ = std::fs::remove_dir_all(wt_dir);
}
eprintln!("Forgot jj workspace {ws_id}");
}
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 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, trunk: &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")?;
let trunk_branch = trunk.split('@').next().unwrap_or(trunk);
let remote = detect_git_remote(project_dir);
let head_output = run_git_utf8(project_dir, &["rev-parse", &format!("{remote}/{trunk_branch}")])
.ok()
.filter(|s| !s.is_empty());
let head = head_output.unwrap_or_else(|| {
run_git_utf8(project_dir, &["rev-parse", "HEAD"]).unwrap_or_default()
});
std::fs::write(format!("{wt_git_dir}/HEAD"), format!("{head}\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}"
);
}
#[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();
setup_git_worktree(&project, &ws, "test-ws", "main").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");
}
}