use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{bail, Context, Result};
use clap_complete::engine::CompletionCandidate;
use rand::Rng;
use serde::{Deserialize, Serialize};
use vcs_runner::{binary_available, jj_divergent_change_ids, run_git_utf8, Cmd};
use crate::claude_trust;
use crate::deps;
use crate::discover::{self, WsRef};
use crate::layout::{self, Config, ResolvedLayout};
use crate::provision::{self, Provisioner, Resource};
use crate::session;
use crate::vcs::Vcs;
const ENV_TEST_LOCAL: &str = ".env.test.local";
const WORKON_JSON: &str = ".workon.json";
const GENERATED_FILES: &[&str] = &[ENV_TEST_LOCAL, WORKON_JSON];
const CLAUDE: &str = "claude";
pub struct WorkspaceOptions<'a> {
pub skip_copy_ignored: bool,
pub label: Option<&'a str>,
pub resume: Option<&'a str>,
pub config: Option<&'a str>,
pub cfg: &'a Config,
}
struct Workspace {
ws_id: String,
name: Option<String>,
project_dir: PathBuf,
project_name: String,
ws_dir: PathBuf,
base: Option<String>,
config: Option<String>,
resources: Vec<Resource>,
}
#[derive(Serialize, Deserialize, Default)]
struct WorkspaceMeta {
#[serde(default)]
base: Option<String>,
#[serde(default)]
config: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
resources: Vec<Resource>,
#[serde(default)]
env: HashMap<String, String>,
#[serde(default)]
session_env: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
created_db: Option<String>,
}
fn write_meta(ws_dir: &Path, meta: &WorkspaceMeta) -> Result<()> {
let path = ws_dir.join(WORKON_JSON);
let json = serde_json::to_string_pretty(meta)?;
let tmp = tempfile::NamedTempFile::new_in(ws_dir)?;
std::fs::write(tmp.path(), json.as_bytes())?;
tmp.persist(&path)?;
Ok(())
}
fn read_meta(ws_dir: &Path) -> WorkspaceMeta {
let path = ws_dir.join(WORKON_JSON);
let Ok(content) = std::fs::read_to_string(&path) else {
return WorkspaceMeta::default();
};
serde_json::from_str(&content).unwrap_or_else(|e| {
eprintln!("workon: ignoring malformed {}: {e}", path.display());
WorkspaceMeta::default()
})
}
pub fn run_workspace(project_dir: &Path, project_name: &str, opts: WorkspaceOptions<'_>, vcs: &dyn Vcs) -> Result<()> {
let WorkspaceOptions { skip_copy_ignored, label, resume, config, cfg } = opts;
let ws = provision(project_dir, project_name, skip_copy_ignored, label, config, vcs)?;
let session_id = attach(&ws, cfg, resume)?;
teardown(&ws, session_id.as_deref(), SaveMode::Prompt, vcs)?;
Ok(())
}
enum SaveMode {
Prompt,
Save,
NoSave,
}
struct TeardownOutcome {
saved: Vec<String>,
dropped: Vec<String>,
}
fn provision(
project_dir: &Path,
project_name: &str,
skip_copy_ignored: bool,
label: Option<&str>,
config: Option<&str>,
vcs: &dyn Vcs,
) -> Result<Workspace> {
let ws = provision_in(
&discover::worktrees_dir()?,
project_dir,
project_name,
skip_copy_ignored,
label,
config,
vcs,
&provision::provisioners(),
)?;
let _ = claude_trust::approve_workspace(&ws.ws_dir);
Ok(ws)
}
#[allow(clippy::too_many_arguments)]
fn provision_in(
worktrees: &Path,
project_dir: &Path,
project_name: &str,
skip_copy_ignored: bool,
label: Option<&str>,
config: Option<&str>,
vcs: &dyn Vcs,
provisioners: &[Box<dyn Provisioner>],
) -> Result<Workspace> {
let ws_id = match label {
Some(l) => format!("{}-{}", generate_ws_id(), slugify(l)),
None => generate_ws_id(),
};
std::fs::create_dir_all(worktrees)?;
let ws_dir = worktrees.join(format!("{project_name}-{ws_id}"));
let trunk = vcs.detect_trunk(project_dir)?;
let base = vcs.create_workspace(project_dir, &ws_dir, &ws_id, &trunk)?;
if !skip_copy_ignored {
vcs.pre_copy_sync(project_dir);
if let Err(e) = copy_gitignored_files(project_dir, &ws_dir) {
eprintln!("Warning: failed to copy gitignored files: {e}");
}
}
if let Err(e) = trust_mise_configs(&ws_dir) {
eprintln!("Warning: failed to trust mise configs: {e}");
}
let mise_vars = crate::mise_env::mise_env("mise", &ws_dir);
let ctx =
provision::ProvisionCtx { project_dir, project_name, ws_id: &ws_id, ws_dir: &ws_dir, mise_vars: &mise_vars };
let mut resources = Vec::new();
let mut env: Vec<(String, String)> = Vec::new();
let mut session_env: Vec<(String, String)> = Vec::new();
for p in provisioners {
if p.detect(&ws_dir) {
match p.setup(&ctx) {
Ok(s) => {
resources.extend(s.resources);
session_env.extend(s.session_env);
if !s.env.is_empty() {
let file = s.env_file.as_deref().unwrap_or(ENV_TEST_LOCAL);
let body = s.env.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("\n");
if std::fs::write(ws_dir.join(file), body).is_ok() {
vcs.ignore_generated_file(project_dir, &ws_dir, file);
}
env.extend(s.env);
}
}
Err(e) => eprintln!("Warning: {} provisioning failed: {e}", p.name()),
}
}
}
let meta = WorkspaceMeta {
base: Some(base.clone()),
config: config.map(String::from),
name: label.map(String::from),
resources: resources.clone(),
env: env.into_iter().collect(),
session_env: session_env.into_iter().collect(),
created_db: None,
};
match write_meta(&ws_dir, &meta) {
Ok(()) => vcs.ignore_generated_file(project_dir, &ws_dir, WORKON_JSON),
Err(e) => eprintln!("Warning: failed to write {WORKON_JSON}: {e}"),
}
Ok(Workspace {
ws_id,
name: label.map(String::from),
project_dir: project_dir.to_path_buf(),
project_name: project_name.to_string(),
ws_dir,
base: Some(base),
config: config.map(String::from),
resources,
})
}
fn attach(ws: &Workspace, cfg: &Config, resume: Option<&str>) -> Result<Option<String>> {
let mise_vars = session_launch_env(&ws.ws_dir);
let tab_name = match ws.name.as_deref() {
Some(l) => capitalize(l),
None => format!("{}-{}", ws.project_name, ws.ws_id),
};
let (ws_layout, session_id) = session_layout(cfg, &ws.ws_dir, resume)?;
session::launch(&tab_name, ws_layout.path(), &ws.ws_dir, &mise_vars)?;
Ok(session_id)
}
fn session_layout(cfg: &Config, ws_dir: &Path, resume: Option<&str>) -> Result<(ResolvedLayout, Option<String>)> {
let Some(agent) = cfg.agent.as_ref() else {
return Ok((cfg.resolve()?, None));
};
if let Some(prev_session_id) = resume {
let Some(args) = agent.resume_args(prev_session_id) else {
bail!("agent '{}' does not declare a 'resume' capability", agent.command);
};
if !cfg.runs_agent() {
bail!("layout has no pane running '{}' to resume into", agent.command);
}
if agent.command == CLAUDE {
migrate_claude_session(prev_session_id, ws_dir);
}
return Ok((cfg.resolve_with_agent_args(&args)?, Some(prev_session_id.to_string())));
}
if !cfg.runs_agent() {
return Ok((cfg.resolve()?, None));
}
let session_id = generate_session_id();
match agent.new_args(&session_id) {
Some(args) => Ok((cfg.resolve_with_agent_args(&args)?, Some(session_id))),
None => Ok((cfg.resolve()?, None)),
}
}
fn is_affirmative(answer: &str) -> bool {
let a = answer.trim();
a.is_empty() || a.eq_ignore_ascii_case("y") || a.eq_ignore_ascii_case("yes")
}
fn should_save(save: &SaveMode, ws_id: &str) -> Result<bool> {
match save {
SaveMode::Save => Ok(true),
SaveMode::NoSave => Ok(false),
SaveMode::Prompt => {
eprint!("Save under workon/{ws_id}? [Y/n] ");
std::io::stderr().flush()?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
Ok(is_affirmative(&answer))
}
}
}
fn teardown(ws: &Workspace, session_id: Option<&str>, save: SaveMode, vcs: &dyn Vcs) -> Result<TeardownOutcome> {
eprintln!();
eprintln!("Cleaning up workspace {}...", ws.ws_id);
if let Some(sid) = session_id {
eprintln!("Agent session: {sid}");
eprintln!(" Resume with: workon -w --resume {sid}");
}
let (meaningful, stranded) = match ws.base.as_deref() {
Some(base) => {
let changed = vcs.changed_files(&ws.ws_id, base, &ws.project_dir, &ws.ws_dir);
let meaningful: Vec<String> =
changed.into_iter().filter(|f| !GENERATED_FILES.contains(&f.as_str())).collect();
let stranded = vcs.stranded_work(&ws.ws_id, base, &ws.project_dir, &ws.ws_dir);
(meaningful, stranded)
}
None => {
eprintln!("Warning: no recorded base commit; skipping unsaved-work detection.");
(Vec::new(), Vec::new())
}
};
let mut saved: Vec<String> = Vec::new();
if !meaningful.is_empty() || !stranded.is_empty() {
eprintln!("Workspace has unsaved work that won't survive teardown:");
for f in &meaningful {
eprintln!(" changed: {f}");
}
for s in &stranded {
eprintln!(" stranded: {s}");
}
if should_save(&save, &ws.ws_id)? {
if !meaningful.is_empty()
&& let Some(base) = ws.base.as_deref()
{
match vcs.save_work(&ws.ws_id, base, &ws.project_dir, &ws.ws_dir) {
Ok(()) => saved.push(format!("workon/{}", ws.ws_id)),
Err(e) => eprintln!("Warning: failed to save work: {e}"),
}
}
for s in &stranded {
if let Some(id) = s.split_whitespace().next() {
match vcs.save_stranded(&ws.project_dir, &ws.ws_id, id) {
Ok(()) => saved.push(format!("workon/{}-{}", ws.ws_id, id)),
Err(e) => eprintln!("Warning: failed to save stranded commit {id}: {e}"),
}
}
}
} else {
eprintln!("Not saved. Recover later with: jj log");
}
}
vcs.forget_workspace(&ws.ws_id, &ws.project_dir, &ws.ws_dir);
let mut dropped = Vec::new();
for resource in &ws.resources {
resource.teardown();
dropped.push(resource.db_name().to_string());
}
match crate::background::remove_dir_in_background(&ws.ws_dir) {
Ok(_) => eprintln!("Removing workspace directory in background"),
Err(_) => {
let _ = std::fs::remove_dir_all(&ws.ws_dir);
eprintln!("Removed workspace directory");
}
}
Ok(TeardownOutcome { saved, dropped })
}
fn load_workspace(reference: Option<&str>) -> Result<Workspace> {
let ws_dir = resolve_ws_dir(reference)?;
let project_dir = discover::project_dir_of(&ws_dir)?;
let project_name =
project_dir.file_name().map(|n| n.to_string_lossy().into_owned()).context("project directory has no name")?;
let ws_id = discover::ws_id_of(&ws_dir, &project_name)
.with_context(|| format!("{} is not named <project>-<ws_id> for project {project_name}", ws_dir.display()))?;
let meta = read_meta(&ws_dir);
let resources = if !meta.resources.is_empty() {
meta.resources
} else if let Some(name) = meta.created_db {
vec![Resource::PostgresDb { name }]
} else {
Vec::new()
};
Ok(Workspace {
ws_id,
name: meta.name,
project_dir,
project_name,
ws_dir,
base: meta.base,
config: meta.config,
resources,
})
}
fn resolve_ws_dir(reference: Option<&str>) -> Result<PathBuf> {
match discover::classify_ref(reference) {
WsRef::Cwd => find_enclosing_workspace(),
WsRef::Path(p) => Ok(p),
WsRef::Token(t) => find_by_token(&t),
}
}
fn find_enclosing_workspace() -> Result<PathBuf> {
let cwd = std::env::current_dir()?;
let root = std::fs::canonicalize(discover::worktrees_dir()?).ok();
for ancestor in cwd.ancestors() {
let parent = std::fs::canonicalize(ancestor).ok().and_then(|p| p.parent().map(Path::to_path_buf));
if parent.is_some() && parent == root {
return Ok(ancestor.to_path_buf());
}
}
bail!("not inside a workspace — pass a ws_id or name (see: workon list)");
}
fn token_matches(token: &str, ws_id: &str, name: Option<&str>) -> bool {
ws_id == token || name.is_some_and(|n| slugify(n) == slugify(token))
}
fn find_by_token(token: &str) -> Result<PathBuf> {
let mut hits: Vec<PathBuf> = Vec::new();
for ws_dir in discover::list_worktree_dirs()? {
let ws_id = discover::project_dir_of(&ws_dir)
.ok()
.and_then(|pd| pd.file_name().map(|n| n.to_string_lossy().into_owned()))
.and_then(|pn| discover::ws_id_of(&ws_dir, &pn));
let name = read_meta(&ws_dir).name;
if token_matches(token, ws_id.as_deref().unwrap_or_default(), name.as_deref()) {
hits.push(ws_dir);
}
}
match hits.len() {
0 => bail!("no workspace matches '{token}' (see: workon list)"),
1 => Ok(hits.remove(0)),
_ => {
let names: Vec<String> = hits.iter().map(|p| path_str(p)).collect();
bail!("'{token}' is ambiguous; matches:\n {}", names.join("\n "))
}
}
}
pub struct CreateArgs<'a> {
pub skip_copy_ignored: bool,
pub name: Option<&'a str>,
pub config: Option<&'a str>,
pub json: bool,
}
pub fn cmd_create(project_dir: &Path, project_name: &str, args: CreateArgs<'_>, vcs: &dyn Vcs) -> Result<()> {
let ws = provision(project_dir, project_name, args.skip_copy_ignored, args.name, args.config, vcs)?;
if args.json {
let dbs: Vec<&str> = ws.resources.iter().map(Resource::db_name).collect();
let obj = serde_json::json!({
"ws_id": ws.ws_id,
"path": path_str(&ws.ws_dir),
"dbs": dbs,
});
println!("{obj}");
} else {
println!("{}", path_str(&ws.ws_dir));
eprintln!("Created workspace {}", ws.ws_id);
eprintln!(" Attach: workon attach {}", ws.ws_id);
eprintln!(" Destroy: workon destroy {}", ws.ws_id);
}
Ok(())
}
pub fn cmd_attach(reference: Option<&str>, config_override: Option<&str>) -> Result<()> {
let ws = load_workspace(reference)?;
let config = config_override.or(ws.config.as_deref());
let cfg = layout::read_config(config)?;
layout::validate_layout(&cfg.layout)?;
deps::check_all(&cfg.layout)?;
cfg.ensure_single_agent_pane()?;
attach(&ws, &cfg, None)?;
Ok(())
}
pub fn ref_candidates() -> Vec<CompletionCandidate> {
match discover::worktrees_dir() {
Ok(root) => ref_candidates_from(&root),
Err(_) => Vec::new(),
}
}
fn ref_candidates_from(worktrees: &Path) -> Vec<CompletionCandidate> {
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
for ws_dir in discover::list_dirs_in(worktrees) {
let project_name = discover::project_dir_of(&ws_dir)
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()));
let help = || project_name.clone().map(Into::into);
if let Some(pn) = project_name.as_deref()
&& let Some(id) = discover::ws_id_of(&ws_dir, pn)
&& seen.insert(id.clone())
{
out.push(CompletionCandidate::new(id).help(help()));
}
if let Some(name) = read_meta(&ws_dir).name {
let slug = slugify(&name);
if !slug.is_empty() && seen.insert(slug.clone()) {
out.push(CompletionCandidate::new(slug).help(help()));
}
}
}
out
}
pub fn cmd_path(reference: Option<&str>) -> Result<()> {
let ws_dir = resolve_ws_dir(reference)?;
println!("{}", path_str(&ws_dir));
Ok(())
}
pub fn cmd_destroy(reference: Option<&str>, no_save: bool, json: bool) -> Result<()> {
let ws = load_workspace(reference)?;
discover::assert_under_worktrees(&ws.ws_dir)?;
let vcs = crate::vcs::detect(&ws.project_dir)?;
let save = if no_save { SaveMode::NoSave } else { SaveMode::Save };
let outcome = teardown(&ws, None, save, &*vcs)?;
if json {
let obj = serde_json::json!({
"ws_id": ws.ws_id,
"saved": outcome.saved,
"dropped": outcome.dropped,
});
println!("{obj}");
}
Ok(())
}
struct WorkspaceRow {
ws_id: String,
name: Option<String>,
age_seconds: u64,
project: Option<String>,
ws_dir: PathBuf,
status: &'static str,
}
pub fn cmd_list(json: bool) -> Result<()> {
let cwd = std::env::current_dir()?;
let cwd_c = std::fs::canonicalize(&cwd).unwrap_or(cwd);
let rows: Vec<WorkspaceRow> =
discover::list_worktree_dirs()?.iter().filter_map(|ws_dir| describe_workspace(ws_dir, &cwd_c)).collect();
if json {
let arr: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"ws_id": r.ws_id,
"name": r.name,
"age_seconds": r.age_seconds,
"project": r.project,
"ws_dir": path_str(&r.ws_dir),
"status": r.status,
})
})
.collect();
println!("{}", serde_json::Value::Array(arr));
return Ok(());
}
if rows.is_empty() {
eprintln!("No workspaces.");
return Ok(());
}
println!("{}", list_row("WORKSPACE", "NAME", "AGE", "PROJECT", "STATUS"));
for r in &rows {
println!(
"{}",
list_row(
&r.ws_id,
r.name.as_deref().unwrap_or("-"),
&humanize_age(r.age_seconds),
r.project.as_deref().unwrap_or("-"),
r.status,
)
);
}
Ok(())
}
fn list_row(ws_id: &str, name: &str, age: &str, project: &str, status: &str) -> String {
format!("{ws_id:<26} {name:<16} {age:>5} {project:<20} {status}")
}
fn describe_workspace(ws_dir: &Path, cwd_c: &Path) -> Option<WorkspaceRow> {
let age_seconds = workspace_age_seconds(ws_dir);
let name = read_meta(ws_dir).name;
let dir_basename = || ws_dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
match discover::project_dir_of(ws_dir) {
Ok(project_dir) => {
let project_dir_c = std::fs::canonicalize(&project_dir).unwrap_or_else(|_| project_dir.clone());
if !project_dir_c.starts_with(cwd_c) {
return None;
}
let project_name = project_dir.file_name().map(|n| n.to_string_lossy().into_owned());
let ws_id =
project_name.as_deref().and_then(|pn| discover::ws_id_of(ws_dir, pn)).unwrap_or_else(dir_basename);
Some(WorkspaceRow {
ws_id,
name,
age_seconds,
project: project_name,
ws_dir: ws_dir.to_path_buf(),
status: active_status(&project_dir),
})
}
Err(_) => Some(WorkspaceRow {
ws_id: dir_basename(),
name,
age_seconds,
project: None,
ws_dir: ws_dir.to_path_buf(),
status: "stale",
}),
}
}
fn active_status(project_dir: &Path) -> &'static str {
let divergent =
project_dir.join(".jj").is_dir() && jj_divergent_change_ids(project_dir).is_ok_and(|ids| !ids.is_empty());
if divergent {
"divergent"
} else {
"active"
}
}
fn workspace_age_seconds(ws_dir: &Path) -> u64 {
std::fs::metadata(ws_dir).and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).map_or(0, |d| d.as_secs())
}
fn humanize_age(secs: u64) -> String {
const MIN: u64 = 60;
const HOUR: u64 = 60 * MIN;
const DAY: u64 = 24 * HOUR;
if secs < MIN {
format!("{secs}s")
} else if secs < HOUR {
format!("{}m", secs / MIN)
} else if secs < DAY {
format!("{}h", secs / HOUR)
} else {
format!("{}d", secs / DAY)
}
}
fn copy_gitignored_files(project_dir: &Path, ws_dir: &Path) -> Result<()> {
let files = enumerate_gitignored_files(project_dir)?;
if files.is_empty() {
return Ok(());
}
let total = files.len();
let rate = if cfg!(target_os = "macos") { 20_000 } else { 3_000 };
let est_secs = total / rate;
if est_secs >= 2 {
eprintln!("Cloning {total} gitignored files (~{est_secs}s, skip with --skip-copy-ignored)...");
} else {
eprintln!("Cloning {total} gitignored files (skip with --skip-copy-ignored)...");
}
let cancelled = AtomicBool::new(false);
let silent = AtomicBool::new(false);
do_copy_files(project_dir, ws_dir, &files, &cancelled, &silent);
Ok(())
}
fn enumerate_gitignored_files(project_dir: &Path) -> Result<Vec<String>> {
let stdout = run_git_utf8(project_dir, &["ls-files", "--others", "--ignored", "--exclude-standard"])
.map_err(|e| anyhow::anyhow!("failed to list gitignored files: {e}"))?;
Ok(stdout
.lines()
.filter(|l| !l.is_empty() && !l.starts_with(".jj/"))
.map(|l| l.strip_suffix('/').unwrap_or(l).to_string())
.collect())
}
fn do_copy_files(project_dir: &Path, ws_dir: &Path, files: &[String], cancelled: &AtomicBool, silent: &AtomicBool) {
let total = files.len();
let mut top_level: Vec<String> = files
.iter()
.map(|l| match l.find('/') {
Some(i) => l[..i].to_string(),
None => l.clone(),
})
.collect();
top_level.sort_unstable();
top_level.dedup();
let opts = clonetree::Options::new();
let mut cloned: std::collections::HashSet<&str> = std::collections::HashSet::new();
for name in &top_level {
if cancelled.load(Ordering::Relaxed) {
return;
}
let src = project_dir.join(name);
let dst = ws_dir.join(name);
if dst.exists() {
continue;
}
if src.is_dir() {
if clonetree::clone_tree(&src, &dst, &opts).is_ok() {
cloned.insert(name);
}
} else if src.is_file() && std::fs::copy(&src, &dst).is_ok() {
cloned.insert(name);
}
}
let stragglers: Vec<&str> = files
.iter()
.filter(|l| {
let top = match l.find('/') {
Some(i) => &l[..i],
None => l.as_str(),
};
!cloned.contains(top)
})
.map(String::as_str)
.collect();
let mut copied = 0usize;
for rel_path in &stragglers {
if cancelled.load(Ordering::Relaxed) {
return;
}
let dst = ws_dir.join(rel_path);
if dst.exists() {
continue;
}
let src = project_dir.join(rel_path);
if src.is_dir() {
if let Some(parent) = dst.parent() {
let _ = std::fs::create_dir_all(parent);
}
match clonetree::clone_tree(&src, &dst, &opts) {
Ok(()) => copied += 1,
Err(e) => {
if !silent.load(Ordering::Relaxed) {
eprintln!("\rWarning: could not clone dir {rel_path}: {e}");
}
}
}
} else if src.is_file() {
if let Some(parent) = dst.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::copy(&src, &dst) {
if !silent.load(Ordering::Relaxed) {
eprintln!("\rWarning: could not copy {rel_path}: {e}");
}
} else {
copied += 1;
}
}
}
if !silent.load(Ordering::Relaxed) {
eprintln!("Cloned {total} gitignored files ({} dirs cloned, {copied} copied individually)", cloned.len(),);
}
}
fn session_launch_env(ws_dir: &Path) -> HashMap<String, String> {
let mut vars = crate::mise_env::mise_env("mise", ws_dir);
vars.extend(read_meta(ws_dir).session_env);
vars
}
fn trust_mise_configs(ws_dir: &Path) -> Result<()> {
if !binary_available("mise") {
return Ok(());
}
let configs = find_mise_configs(ws_dir);
for config_path in &configs {
let display = config_path.strip_prefix(ws_dir).unwrap_or(config_path);
match Cmd::new("mise").args(["trust", &path_str(config_path)]).run() {
Ok(_) => eprintln!("Trusted mise config: {}", display.display()),
Err(_) => eprintln!("Warning: could not trust mise config: {}", display.display()),
}
}
if !configs.is_empty() {
warn_mise_shims();
}
Ok(())
}
fn mise_activated() -> bool {
["MISE_SHELL", "__MISE_DIFF", "__MISE_SESSION"].iter().any(|k| std::env::var_os(k).is_some_and(|v| !v.is_empty()))
}
fn should_warn_mise_shims(activated: bool, shims_would_help: bool) -> bool {
!activated && shims_would_help
}
fn warn_mise_shims() {
let shims_dir = match home_dir() {
Ok(h) => h.join(".local/share/mise/shims"),
Err(_) => return,
};
let shims_str = path_str(&shims_dir);
let on_path = std::env::var("PATH").unwrap_or_default().split(':').any(|p| p == shims_str);
let shims_would_help = shims_dir.is_dir() && !on_path;
if should_warn_mise_shims(mise_activated(), shims_would_help) {
eprintln!();
eprintln!("Warning: mise shims directory is not on your PATH, and");
eprintln!("`mise activate` isn't set up either. Non-interactive shells");
eprintln!("(e.g. Claude Code) may not pick up the correct tool versions.");
eprintln!();
eprintln!("Add this to ~/.zshenv (sourced by non-interactive shells too):");
eprintln!();
eprintln!(" export PATH=\"$HOME/.local/share/mise/shims:$PATH\"");
eprintln!();
eprintln!("workon will inject the correct env vars for this session,");
eprintln!("but fixing your shell profile avoids the issue everywhere.");
eprintln!();
}
}
const MISE_CONFIG_NAMES: &[&str] = &[".mise.toml", ".mise.local.toml", "mise.toml", ".tool-versions"];
fn find_mise_configs(dir: &Path) -> Vec<PathBuf> {
let mut configs = Vec::new();
find_mise_configs_recursive(dir, &mut configs, 0);
configs
}
fn find_mise_configs_recursive(dir: &Path, configs: &mut Vec<PathBuf>, depth: u32) {
if depth > 3 {
return;
}
for name in MISE_CONFIG_NAMES {
let path = dir.join(name);
if path.exists() {
configs.push(path);
}
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.')
|| name == "node_modules"
|| name == "vendor"
|| name == "target"
|| name == "build"
|| name == "dist"
{
continue;
}
find_mise_configs_recursive(&path, configs, depth + 1);
}
}
fn migrate_claude_session(session_id: &str, new_ws_dir: &Path) {
let claude_dir = match home_dir() {
Ok(h) => h.join(".claude").join("projects"),
Err(_) => return,
};
let session_file = format!("{session_id}.jsonl");
let entries = match std::fs::read_dir(&claude_dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let src = entry.path().join(&session_file);
if src.is_file() {
let dest_dir_name = encode_claude_project_path(new_ws_dir);
let dest_dir = claude_dir.join(dest_dir_name);
let _ = std::fs::create_dir_all(&dest_dir);
match std::fs::copy(&src, dest_dir.join(&session_file)) {
Ok(_) => {
eprintln!("Migrated Claude session to new workspace");
return;
}
Err(e) => {
eprintln!("Warning: failed to copy Claude session: {e}");
return;
}
}
}
}
eprintln!("Warning: could not find Claude session {session_id}");
}
fn encode_claude_project_path(path: &Path) -> String {
path_str(path).chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
}
fn generate_ws_id() -> String {
let bytes: [u8; 3] = rand::rng().random();
format!("ws-{:02x}{:02x}{:02x}", bytes[0], bytes[1], bytes[2])
}
fn generate_session_id() -> String {
uuid::Uuid::new_v4().to_string()
}
pub(crate) fn slugify(text: &str) -> String {
text.chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn capitalize(text: &str) -> String {
let mut chars = text.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
}
fn path_str(p: &Path) -> String {
p.to_string_lossy().into_owned()
}
fn home_dir() -> Result<PathBuf> {
crate::home::home_dir()
}
#[cfg(test)]
mod tests {
use std::process::{Command, Stdio};
use super::*;
#[test]
fn workon_json_round_trips_through_write_meta() {
let tmp = tempfile::tempdir().unwrap();
let meta = WorkspaceMeta {
base: Some("a1b2c3".to_string()),
config: Some("opencode".to_string()),
name: Some("fix bug".to_string()),
resources: vec![Resource::PostgresDb { name: "mbc_ws_abc_test".into() }],
env: HashMap::from([("DATABASE_URL".to_string(), "postgresql://localhost/mbc_ws_abc_test".to_string())]),
session_env: HashMap::from([("MIX_TEST_PARTITION".to_string(), "_ws_abc".to_string())]),
created_db: None,
};
write_meta(tmp.path(), &meta).unwrap();
let raw = std::fs::read_to_string(tmp.path().join(WORKON_JSON)).unwrap();
assert!(!raw.contains("created_db"), "{raw}");
let back: WorkspaceMeta = serde_json::from_str(&raw).unwrap();
assert_eq!(back.base.as_deref(), Some("a1b2c3"));
assert_eq!(back.config.as_deref(), Some("opencode"));
assert_eq!(back.name.as_deref(), Some("fix bug"));
assert_eq!(back.resources, vec![Resource::PostgresDb { name: "mbc_ws_abc_test".into() }]);
assert_eq!(back.env.get("DATABASE_URL").map(String::as_str), Some("postgresql://localhost/mbc_ws_abc_test"));
assert_eq!(back.session_env.get("MIX_TEST_PARTITION").map(String::as_str), Some("_ws_abc"));
}
#[test]
fn session_launch_env_includes_persisted_session_env() {
let tmp = tempfile::tempdir().unwrap();
write_meta(
tmp.path(),
&WorkspaceMeta {
session_env: HashMap::from([("MIX_TEST_PARTITION".to_string(), "_ws_abc".to_string())]),
..Default::default()
},
)
.unwrap();
let env = session_launch_env(tmp.path());
assert_eq!(env.get("MIX_TEST_PARTITION").map(String::as_str), Some("_ws_abc"));
}
#[test]
fn read_meta_maps_legacy_created_db_to_a_resource() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join(WORKON_JSON),
r#"{"base":"x","config":null,"name":null,"created_db":"old_ws_test"}"#,
)
.unwrap();
let meta = read_meta(tmp.path());
assert!(meta.resources.is_empty());
assert_eq!(meta.created_db.as_deref(), Some("old_ws_test"));
}
#[test]
fn workon_json_reads_nulls_and_missing_fields_as_none() {
let all_null = serde_json::to_string(&WorkspaceMeta::default()).unwrap();
let back: WorkspaceMeta = serde_json::from_str(&all_null).unwrap();
assert!(back.base.is_none() && back.config.is_none() && back.created_db.is_none());
let empty: WorkspaceMeta = serde_json::from_str("{}").unwrap();
assert!(empty.base.is_none() && empty.name.is_none());
}
#[test]
fn workon_json_is_a_generated_file() {
assert!(GENERATED_FILES.contains(&WORKON_JSON));
}
#[test]
fn token_matches_id_exactly_and_name_by_slug() {
assert!(token_matches("ws-abc123-fix-bug", "ws-abc123-fix-bug", Some("fix bug")));
assert!(token_matches("fix bug", "ws-abc123-fix-bug", Some("fix bug")));
assert!(token_matches("fix-bug", "ws-abc123-fix-bug", Some("fix bug")));
assert!(token_matches("Fix Bug", "ws-abc123-fix-bug", Some("fix bug")));
assert!(!token_matches("other", "ws-abc123-fix-bug", Some("fix bug")));
assert!(!token_matches("fix", "ws-abc123-fix-bug", Some("fix bug")));
assert!(token_matches("ws-abc123", "ws-abc123", None));
assert!(!token_matches("anything", "ws-abc123", None));
}
#[test]
fn humanize_age_scales_by_unit() {
assert_eq!(humanize_age(5), "5s");
assert_eq!(humanize_age(59), "59s");
assert_eq!(humanize_age(60), "1m");
assert_eq!(humanize_age(3599), "59m");
assert_eq!(humanize_age(3600), "1h");
assert_eq!(humanize_age(86_399), "23h");
assert_eq!(humanize_age(86_400), "1d");
assert_eq!(humanize_age(200_000), "2d");
}
fn git(dir: &Path, args: &[&str]) {
let ok = Command::new("git")
.args(args)
.current_dir(dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap()
.success();
assert!(ok, "git {args:?} failed in {}", dir.display());
}
#[test]
fn active_status_is_active_for_non_jj_project() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(active_status(tmp.path()), "active");
}
#[test]
fn active_status_reports_divergent_after_op_log_fork() {
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("f.txt"), "a").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "init"]);
vcs_runner::run_jj(&repo, &["git", "init", "--colocate"]).unwrap();
vcs_runner::run_jj(&repo, &["describe", "-m", "A"]).unwrap();
vcs_runner::run_jj(&repo, &["new", "-m", "B"]).unwrap();
assert_eq!(active_status(&repo), "active", "a clean colocated repo is active");
let ops = vcs_runner::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();
vcs_runner::run_jj(&repo, &["--at-operation", &earlier, "describe", "-m", "FORKED"]).unwrap();
let _ = vcs_runner::run_jj(&repo, &["status"]);
assert_eq!(active_status(&repo), "divergent", "a forked op log surfaces as divergent");
}
fn repo_with_worktree(root: &Path, ws_id: &str) -> (PathBuf, PathBuf) {
let project = root.join("myproj");
std::fs::create_dir(&project).unwrap();
git(&project, &["init", "-q"]);
git(&project, &["config", "user.email", "t@example.com"]);
git(&project, &["config", "user.name", "Tester"]);
git(&project, &["commit", "-q", "--allow-empty", "-m", "init"]);
let ws_dir = root.join(format!("myproj-{ws_id}"));
git(&project, &["worktree", "add", "-q", "--detach", ws_dir.to_str().unwrap(), "HEAD"]);
(project, ws_dir)
}
fn git_out(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git").args(args).current_dir(dir).output().unwrap();
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn cloned_repo(root: &Path) -> PathBuf {
let origin = root.join("origin.git");
let repo = root.join("proj");
git(root, &["init", "-q", "--bare", "--initial-branch=main", origin.to_str().unwrap()]);
git(root, &["clone", "-q", origin.to_str().unwrap(), repo.to_str().unwrap()]);
git(&repo, &["config", "user.email", "t@example.com"]);
git(&repo, &["config", "user.name", "Tester"]);
git(&repo, &["commit", "-q", "--allow-empty", "-m", "init"]);
git(&repo, &["push", "-q", "-u", "origin", "main"]);
repo
}
#[test]
fn provision_in_creates_worktree_and_records_provenance() {
let tmp = tempfile::tempdir().unwrap();
let repo = cloned_repo(tmp.path());
let worktrees = tmp.path().join("worktrees");
let ws = provision_in(
&worktrees,
&repo,
"proj",
true, Some("fix bug"),
Some("opencode"),
&crate::vcs::GitBackend,
&provision::provisioners(),
)
.unwrap();
assert!(ws.ws_dir.starts_with(&worktrees), "worktree under the injected root");
assert!(ws.ws_dir.is_dir());
assert!(ws.base.is_some(), "base pinned from trunk");
assert_eq!(ws.name.as_deref(), Some("fix bug"));
assert_eq!(ws.config.as_deref(), Some("opencode"));
assert!(ws.resources.is_empty(), "no database.yml -> no DB provisioned");
let meta = read_meta(&ws.ws_dir);
assert_eq!(meta.base, ws.base);
assert_eq!(meta.config.as_deref(), Some("opencode"));
assert_eq!(meta.name.as_deref(), Some("fix bug"));
}
#[test]
fn provision_in_copies_gitignored_files_unless_told_to_skip() {
let tmp = tempfile::tempdir().unwrap();
let repo = cloned_repo(tmp.path());
std::fs::write(repo.join(".git/info/exclude"), ".env\n").unwrap();
std::fs::write(repo.join(".env"), "SECRET=1\n").unwrap();
let worktrees = tmp.path().join("worktrees");
let none: Vec<Box<dyn Provisioner>> = Vec::new();
let copied =
provision_in(&worktrees, &repo, "proj", false, None, None, &crate::vcs::GitBackend, &none).unwrap();
let skipped =
provision_in(&worktrees, &repo, "proj", true, None, None, &crate::vcs::GitBackend, &none).unwrap();
assert_eq!(std::fs::read_to_string(copied.ws_dir.join(".env")).unwrap(), "SECRET=1\n");
assert!(!skipped.ws_dir.join(".env").exists(), "--skip-copy-ignored still copied .env");
}
struct MockProvisioner;
impl Provisioner for MockProvisioner {
fn name(&self) -> &'static str {
"mock"
}
fn detect(&self, _ws_dir: &Path) -> bool {
true
}
fn setup(&self, ctx: &provision::ProvisionCtx<'_>) -> Result<provision::Setup> {
Ok(provision::Setup {
resources: vec![Resource::PostgresDb { name: format!("mock_{}_test", ctx.ws_id) }],
env: vec![("DATABASE_URL".to_string(), "postgresql://localhost/mock".to_string())],
session_env: vec![("MOCK_SESSION_VAR".to_string(), ctx.ws_id.to_string())],
..Default::default()
})
}
}
#[test]
fn provision_collects_resources_and_writes_env() {
let tmp = tempfile::tempdir().unwrap();
let repo = cloned_repo(tmp.path());
let worktrees = tmp.path().join("worktrees");
let mock: Vec<Box<dyn Provisioner>> = vec![Box::new(MockProvisioner)];
let ws = provision_in(&worktrees, &repo, "proj", true, None, None, &crate::vcs::GitBackend, &mock).unwrap();
assert_eq!(ws.resources.len(), 1);
assert!(ws.resources[0].db_name().starts_with("mock_"));
let envfile = std::fs::read_to_string(ws.ws_dir.join(ENV_TEST_LOCAL)).unwrap();
assert!(envfile.contains("DATABASE_URL=postgresql://localhost/mock"), "{envfile}");
let meta = read_meta(&ws.ws_dir);
assert_eq!(meta.resources, ws.resources);
assert_eq!(meta.env.get("DATABASE_URL").map(String::as_str), Some("postgresql://localhost/mock"));
assert_eq!(meta.session_env.get("MOCK_SESSION_VAR").map(String::as_str), Some(ws.ws_id.as_str()));
assert!(!envfile.contains("MOCK_SESSION_VAR"), "session_env must not leak into the dotenv file: {envfile}");
}
#[test]
fn teardown_reports_dropped_resources() {
let tmp = tempfile::tempdir().unwrap();
let repo = cloned_repo(tmp.path());
let worktrees = tmp.path().join("worktrees");
let mock: Vec<Box<dyn Provisioner>> = vec![Box::new(MockProvisioner)];
let ws = provision_in(&worktrees, &repo, "proj", true, None, None, &crate::vcs::GitBackend, &mock).unwrap();
let outcome = teardown(&ws, None, SaveMode::NoSave, &crate::vcs::GitBackend).unwrap();
assert_eq!(outcome.dropped.len(), 1);
assert!(outcome.dropped[0].starts_with("mock_"));
}
#[test]
fn teardown_no_save_forgets_worktree_and_reports_nothing() {
let tmp = tempfile::tempdir().unwrap();
let repo = cloned_repo(tmp.path());
let worktrees = tmp.path().join("worktrees");
let ws = provision_in(
&worktrees,
&repo,
"proj",
true,
None,
None,
&crate::vcs::GitBackend,
&provision::provisioners(),
)
.unwrap();
let ws_dir = ws.ws_dir.clone();
let outcome = teardown(&ws, None, SaveMode::NoSave, &crate::vcs::GitBackend).unwrap();
assert!(outcome.saved.is_empty());
assert!(outcome.dropped.is_empty());
assert!(!git_out(&repo, &["worktree", "list"]).contains(ws_dir.to_str().unwrap()));
}
#[test]
fn teardown_save_rescues_committed_work_as_a_branch() {
let tmp = tempfile::tempdir().unwrap();
let repo = cloned_repo(tmp.path());
let worktrees = tmp.path().join("worktrees");
let ws = provision_in(
&worktrees,
&repo,
"proj",
true,
None,
None,
&crate::vcs::GitBackend,
&provision::provisioners(),
)
.unwrap();
std::fs::write(ws.ws_dir.join("new.txt"), "work").unwrap();
git(&ws.ws_dir, &["add", "-A"]);
git(&ws.ws_dir, &["commit", "-q", "-m", "wip"]);
let outcome = teardown(&ws, None, SaveMode::Save, &crate::vcs::GitBackend).unwrap();
let branch = format!("workon/{}", ws.ws_id);
assert!(outcome.saved.contains(&branch), "reported saved: {:?}", outcome.saved);
assert!(git_out(&repo, &["branch", "--list", &branch]).contains(&branch));
}
#[test]
fn teardown_no_save_discards_committed_work() {
let tmp = tempfile::tempdir().unwrap();
let repo = cloned_repo(tmp.path());
let worktrees = tmp.path().join("worktrees");
let ws = provision_in(
&worktrees,
&repo,
"proj",
true,
None,
None,
&crate::vcs::GitBackend,
&provision::provisioners(),
)
.unwrap();
std::fs::write(ws.ws_dir.join("new.txt"), "work").unwrap();
git(&ws.ws_dir, &["add", "-A"]);
git(&ws.ws_dir, &["commit", "-q", "-m", "wip"]);
let outcome = teardown(&ws, None, SaveMode::NoSave, &crate::vcs::GitBackend).unwrap();
assert!(outcome.saved.is_empty());
let branch = format!("workon/{}", ws.ws_id);
assert!(!git_out(&repo, &["branch", "--list", &branch]).contains(&branch), "no branch created");
}
#[test]
fn ref_candidates_offers_ws_id_and_nickname_slug() {
let tmp = tempfile::tempdir().unwrap();
let repo = cloned_repo(tmp.path());
let worktrees = tmp.path().join("worktrees");
let ws = provision_in(
&worktrees,
&repo,
"proj",
true,
Some("fix bug"),
None,
&crate::vcs::GitBackend,
&provision::provisioners(),
)
.unwrap();
let values: Vec<String> =
ref_candidates_from(&worktrees).iter().map(|c| c.get_value().to_string_lossy().into_owned()).collect();
assert!(values.contains(&ws.ws_id), "ws_id offered: {values:?}");
assert!(values.iter().any(|v| v == "fix-bug"), "nickname slug offered: {values:?}");
}
#[test]
fn ref_candidates_offer_a_shared_nickname_once() {
let tmp = tempfile::tempdir().unwrap();
let repo = cloned_repo(tmp.path());
let worktrees = tmp.path().join("worktrees");
for _ in 0..2 {
provision_in(&worktrees, &repo, "proj", true, Some("fix bug"), None, &crate::vcs::GitBackend, &[]).unwrap();
}
let values: Vec<String> =
ref_candidates_from(&worktrees).iter().map(|c| c.get_value().to_string_lossy().into_owned()).collect();
assert_eq!(values.iter().filter(|v| *v == "fix-bug").count(), 1, "{values:?}");
}
#[test]
fn list_row_lines_up_its_columns() {
assert_eq!(
list_row("ws-a1b2c3", "fix", "3m", "proj", "active"),
format!("ws-a1b2c3{} fix{} 3m proj{} active", " ".repeat(17), " ".repeat(13), " ".repeat(16))
);
}
#[test]
fn describe_workspace_skips_projects_outside_cwd() {
let tmp = tempfile::tempdir().unwrap();
let repo = cloned_repo(tmp.path());
let elsewhere = tmp.path().join("elsewhere");
std::fs::create_dir(&elsewhere).unwrap();
let worktrees = tmp.path().join("worktrees");
let ws = provision_in(&worktrees, &repo, "proj", true, None, None, &crate::vcs::GitBackend, &[]).unwrap();
let inside = describe_workspace(&ws.ws_dir, &std::fs::canonicalize(&repo).unwrap());
let outside = describe_workspace(&ws.ws_dir, &std::fs::canonicalize(&elsewhere).unwrap());
assert_eq!(inside.map(|r| r.ws_id), Some(ws.ws_id));
assert!(outside.is_none(), "a project outside cwd was listed");
}
#[test]
fn ref_candidates_empty_for_missing_root() {
let tmp = tempfile::tempdir().unwrap();
assert!(ref_candidates_from(&tmp.path().join("nope")).is_empty());
}
#[test]
fn load_workspace_recovers_structure_and_meta_from_a_worktree() {
let tmp = tempfile::tempdir().unwrap();
let (_project, ws_dir) = repo_with_worktree(tmp.path(), "ws-abc123");
write_meta(
&ws_dir,
&WorkspaceMeta {
base: Some("deadbeef".into()),
config: Some("opencode".into()),
name: Some("Fix Bug".into()),
resources: vec![Resource::PostgresDb { name: "myproj_ws_abc123_test".into() }],
env: HashMap::new(),
session_env: HashMap::new(),
created_db: None,
},
)
.unwrap();
let ws = load_workspace(Some(ws_dir.to_str().unwrap())).unwrap();
assert_eq!(ws.ws_id, "ws-abc123");
assert_eq!(ws.project_name, "myproj");
assert_eq!(ws.project_dir.file_name().unwrap(), "myproj");
assert_eq!(ws.base.as_deref(), Some("deadbeef"));
assert_eq!(ws.config.as_deref(), Some("opencode"));
assert_eq!(ws.name.as_deref(), Some("Fix Bug"));
assert_eq!(ws.resources, vec![Resource::PostgresDb { name: "myproj_ws_abc123_test".into() }]);
}
#[test]
fn load_workspace_maps_legacy_created_db_to_a_resource() {
let tmp = tempfile::tempdir().unwrap();
let (_project, ws_dir) = repo_with_worktree(tmp.path(), "ws-legacy");
std::fs::write(
ws_dir.join(WORKON_JSON),
r#"{"base":"x","config":null,"name":null,"created_db":"myproj_ws_legacy_test"}"#,
)
.unwrap();
let ws = load_workspace(Some(ws_dir.to_str().unwrap())).unwrap();
assert_eq!(ws.resources, vec![Resource::PostgresDb { name: "myproj_ws_legacy_test".into() }]);
}
#[test]
fn load_workspace_tolerates_absent_meta() {
let tmp = tempfile::tempdir().unwrap();
let (_project, ws_dir) = repo_with_worktree(tmp.path(), "ws-nometa");
let ws = load_workspace(Some(ws_dir.to_str().unwrap())).unwrap();
assert_eq!(ws.ws_id, "ws-nometa");
assert!(ws.base.is_none());
assert!(ws.config.is_none());
assert!(ws.name.is_none());
}
#[test]
fn is_affirmative_defaults_to_yes() {
assert!(is_affirmative(""));
assert!(is_affirmative("\n"));
assert!(is_affirmative("y"));
assert!(is_affirmative("Y\n"));
assert!(is_affirmative("yes"));
}
#[test]
fn is_affirmative_explicit_no_declines() {
assert!(!is_affirmative("n"));
assert!(!is_affirmative("N\n"));
assert!(!is_affirmative("no"));
assert!(!is_affirmative("nope"));
}
#[test]
fn ws_id_format() {
let id = generate_ws_id();
assert!(id.starts_with("ws-"));
assert_eq!(id.len(), 9); assert!(id[3..].chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn encode_claude_project_path_matches_claude_convention() {
let p = Path::new("/Users/foo/.worktrees/bar-ws-abc123");
assert_eq!(encode_claude_project_path(p), "-Users-foo--worktrees-bar-ws-abc123");
}
#[test]
fn session_id_is_valid_uuid() {
let id = generate_session_id();
assert!(uuid::Uuid::parse_str(&id).is_ok(), "should be a valid UUID: {id}");
}
mod session_layout {
use super::*;
fn layout_text(resolved: &ResolvedLayout) -> String {
std::fs::read_to_string(resolved.path()).unwrap()
}
fn config(src: &str) -> Config {
Config::parse(src).unwrap()
}
#[test]
fn no_agent_means_no_session_id_and_no_injection() {
let cfg = config("workon {\n}\nlayout {\n pane command=\"vim\"\n}");
let (resolved, id) = session_layout(&cfg, Path::new("/tmp"), None).unwrap();
assert_eq!(id, None, "no agent: nothing to resume, so no hint");
assert_eq!(layout_text(&resolved), cfg.layout);
}
#[test]
fn fresh_session_mints_an_id_and_injects_it() {
let cfg = config(
"workon {\n agent command=\"vim\" {\n new \"--id\" \"{session_id}\"\n }\n}\n\
layout {\n pane command=\"vim\"\n}",
);
let (resolved, id) = session_layout(&cfg, Path::new("/tmp"), None).unwrap();
let id = id.expect("an agent taking --id should get a minted session id");
assert!(uuid::Uuid::parse_str(&id).is_ok(), "should be a UUID: {id}");
assert!(layout_text(&resolved).contains(&id), "the minted id must reach the pane");
}
#[test]
fn agent_without_new_capability_gets_no_id_and_no_injection() {
let cfg = config(
"workon {\n agent command=\"vim\" {\n resume \"-r\" \"{session_id}\"\n }\n}\n\
layout {\n pane command=\"vim\"\n}",
);
let (resolved, id) = session_layout(&cfg, Path::new("/tmp"), None).unwrap();
assert_eq!(id, None, "workon can't know an id it never handed over");
assert_eq!(layout_text(&resolved), cfg.layout);
}
#[test]
fn resume_echoes_the_id_and_injects_resume_args() {
let cfg = config(
"workon {\n agent command=\"vim\" {\n resume \"-r\" \"{session_id}\"\n }\n}\n\
layout {\n pane command=\"vim\"\n}",
);
let (resolved, id) = session_layout(&cfg, Path::new("/tmp"), Some("prev-id")).unwrap();
assert_eq!(id.as_deref(), Some("prev-id"));
assert!(layout_text(&resolved).contains(r#"args "-r" "prev-id""#));
}
#[test]
fn resume_against_an_agent_that_cannot_resume_is_refused() {
let cfg = config(
"workon {\n agent command=\"vim\" {\n new \"--id\" \"{session_id}\"\n }\n}\n\
layout {\n pane command=\"vim\"\n}",
);
let err = session_layout(&cfg, Path::new("/tmp"), Some("prev-id")).unwrap_err();
assert!(err.to_string().contains("does not declare a 'resume' capability"), "{err}");
}
#[test]
fn declared_agent_the_layout_never_runs_gets_no_id_and_no_hint() {
let cfg = config("layout {\n pane command=\"opencode\"\n}");
assert!(cfg.agent.is_some(), "compatibility default names claude");
assert!(!cfg.runs_agent(), "but nothing runs it");
let (resolved, id) = session_layout(&cfg, Path::new("/tmp"), None).unwrap();
assert_eq!(id, None, "no pane received an id, so offer no resume");
assert_eq!(layout_text(&resolved), cfg.layout);
}
#[test]
fn resume_into_a_layout_missing_the_agent_pane_is_refused() {
let cfg = config("layout {\n pane command=\"opencode\"\n}");
let err = session_layout(&cfg, Path::new("/tmp"), Some("prev-id")).unwrap_err();
assert!(err.to_string().contains("no pane running 'claude'"), "{err}");
}
#[test]
fn injected_args_merge_with_args_the_user_wrote() {
let cfg = config(
"workon {\n agent command=\"vim\" {\n new \"--id\" \"{session_id}\"\n }\n}\n\
layout {\n pane command=\"vim\" {\n args \"--clean\"\n }\n}",
);
let (resolved, id) = session_layout(&cfg, Path::new("/tmp"), None).unwrap();
let text = layout_text(&resolved);
assert_eq!(text.matches("args ").count(), 1, "exactly one args node: {text}");
assert!(text.contains(&format!(r#"args "--clean" "--id" "{}""#, id.unwrap())), "{text}");
}
}
#[test]
fn ws_id_is_random() {
let a = generate_ws_id();
let b = generate_ws_id();
assert_ne!(a, b);
}
#[test]
fn slugify_converts_text_to_slug() {
assert_eq!(slugify("HD Ticket #12345"), "hd-ticket-12345");
assert_eq!(slugify("Fix the BUG"), "fix-the-bug");
assert_eq!(slugify(" leading/trailing "), "leading-trailing");
assert_eq!(slugify("a--b"), "a-b");
assert_eq!(slugify("simple"), "simple");
}
#[test]
fn capitalize_uppercases_first_char() {
assert_eq!(capitalize("fix tests"), "Fix tests");
assert_eq!(capitalize("HD Ticket"), "HD Ticket");
assert_eq!(capitalize(""), "");
assert_eq!(capitalize("a"), "A");
}
#[test]
fn copy_gitignored_files_copies_ignored_files() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&ws).unwrap();
Command::new("git")
.args(["init", &path_str(&project)])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
std::fs::write(project.join(".gitignore"), "secret.key\nconfig/creds/\n").unwrap();
std::fs::write(project.join("tracked.txt"), "hello").unwrap();
std::fs::write(project.join("secret.key"), "supersecret").unwrap();
std::fs::create_dir_all(project.join("config/creds")).unwrap();
std::fs::write(project.join("config/creds/master.key"), "key123").unwrap();
Command::new("git")
.args(["-C", &path_str(&project), "add", ".gitignore", "tracked.txt"])
.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();
copy_gitignored_files(&project, &ws).unwrap();
assert_eq!(std::fs::read_to_string(ws.join("secret.key")).unwrap(), "supersecret");
assert_eq!(std::fs::read_to_string(ws.join("config/creds/master.key")).unwrap(), "key123");
}
#[test]
fn copy_gitignored_files_skips_jj_directory() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&ws).unwrap();
Command::new("git")
.args(["init", &path_str(&project)])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
std::fs::write(project.join(".gitignore"), ".jj/\nsecret.key\n").unwrap();
std::fs::write(project.join("secret.key"), "keep").unwrap();
std::fs::create_dir_all(project.join(".jj/repo")).unwrap();
std::fs::write(project.join(".jj/repo/store"), "corrupt").unwrap();
Command::new("git")
.args(["-C", &path_str(&project), "add", ".gitignore"])
.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();
copy_gitignored_files(&project, &ws).unwrap();
assert_eq!(std::fs::read_to_string(ws.join("secret.key")).unwrap(), "keep");
assert!(!ws.join(".jj").exists(), ".jj/ should not be copied");
}
#[test]
fn copy_gitignored_files_skips_existing() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&ws).unwrap();
Command::new("git")
.args(["init", &path_str(&project)])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
std::fs::write(project.join(".gitignore"), "*.key\n").unwrap();
std::fs::write(project.join("secret.key"), "from_project").unwrap();
Command::new("git")
.args(["-C", &path_str(&project), "add", ".gitignore"])
.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();
std::fs::write(ws.join("secret.key"), "already_here").unwrap();
copy_gitignored_files(&project, &ws).unwrap();
assert_eq!(std::fs::read_to_string(ws.join("secret.key")).unwrap(), "already_here");
}
#[test]
fn copy_gitignored_files_clones_directories() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&ws).unwrap();
Command::new("git")
.args(["init", &path_str(&project)])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
std::fs::write(project.join(".gitignore"), "build/\nsecret.key\n").unwrap();
std::fs::write(project.join("tracked.txt"), "hello").unwrap();
std::fs::write(project.join("secret.key"), "root_file").unwrap();
std::fs::create_dir_all(project.join("build/sub")).unwrap();
std::fs::write(project.join("build/out.o"), "compiled").unwrap();
std::fs::write(project.join("build/sub/lib.a"), "archive").unwrap();
Command::new("git")
.args(["-C", &path_str(&project), "add", ".gitignore", "tracked.txt"])
.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();
copy_gitignored_files(&project, &ws).unwrap();
assert_eq!(std::fs::read_to_string(ws.join("secret.key")).unwrap(), "root_file");
assert_eq!(std::fs::read_to_string(ws.join("build/out.o")).unwrap(), "compiled");
assert_eq!(std::fs::read_to_string(ws.join("build/sub/lib.a")).unwrap(), "archive");
}
#[test]
fn copy_gitignored_files_falls_back_for_partially_existing_dir() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&ws).unwrap();
Command::new("git")
.args(["init", &path_str(&project)])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
std::fs::write(project.join(".gitignore"), "config/creds/\n").unwrap();
std::fs::create_dir_all(project.join("config/creds")).unwrap();
std::fs::write(project.join("config/settings.toml"), "tracked").unwrap();
std::fs::write(project.join("config/creds/secret.key"), "hidden").unwrap();
Command::new("git")
.args(["-C", &path_str(&project), "add", ".gitignore", "config/settings.toml"])
.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();
std::fs::create_dir_all(ws.join("config")).unwrap();
std::fs::write(ws.join("config/settings.toml"), "tracked").unwrap();
copy_gitignored_files(&project, &ws).unwrap();
assert_eq!(std::fs::read_to_string(ws.join("config/creds/secret.key")).unwrap(), "hidden");
assert_eq!(std::fs::read_to_string(ws.join("config/settings.toml")).unwrap(), "tracked");
}
#[test]
fn copy_gitignored_files_clones_nested_git_repos_as_stragglers() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&ws).unwrap();
Command::new("git")
.args(["init", &path_str(&project)])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
std::fs::write(project.join(".gitignore"), "vendor/bundle/\n").unwrap();
std::fs::create_dir_all(project.join("vendor")).unwrap();
std::fs::write(project.join("vendor/tracked.txt"), "tracked").unwrap();
let gem_dir = project.join("vendor/bundle/gems/some_gem-abc123");
std::fs::create_dir_all(&gem_dir).unwrap();
std::fs::write(gem_dir.join("lib.rb"), "puts 'hello'").unwrap();
Command::new("git")
.args(["init", &path_str(&gem_dir)])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
Command::new("git")
.args(["-C", &path_str(&gem_dir), "add", "."])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
Command::new("git")
.args(["-C", &path_str(&gem_dir), "commit", "-m", "init"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
Command::new("git")
.args(["-C", &path_str(&project), "add", ".gitignore", "vendor/tracked.txt"])
.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();
std::fs::create_dir_all(ws.join("vendor")).unwrap();
std::fs::write(ws.join("vendor/tracked.txt"), "tracked").unwrap();
copy_gitignored_files(&project, &ws).unwrap();
assert!(
ws.join("vendor/bundle/gems/some_gem-abc123/lib.rb").exists(),
"nested git repo content should be copied to workspace"
);
assert_eq!(
std::fs::read_to_string(ws.join("vendor/bundle/gems/some_gem-abc123/lib.rb")).unwrap(),
"puts 'hello'"
);
assert!(
ws.join("vendor/bundle/gems/some_gem-abc123/.git").exists(),
"nested .git directory should be cloned too"
);
}
#[test]
fn find_mise_configs_finds_root_and_subdirs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join(".mise.toml"), "").unwrap();
std::fs::create_dir_all(root.join("services/api")).unwrap();
std::fs::write(root.join("services/api/.mise.toml"), "").unwrap();
std::fs::create_dir_all(root.join("services/web")).unwrap();
std::fs::write(root.join("services/web/.tool-versions"), "").unwrap();
let configs = find_mise_configs(root);
let rel: Vec<_> = configs.iter().map(|p| p.strip_prefix(root).unwrap().to_string_lossy().to_string()).collect();
assert!(rel.contains(&".mise.toml".to_string()));
assert!(rel.contains(&"services/api/.mise.toml".to_string()));
assert!(rel.contains(&"services/web/.tool-versions".to_string()));
}
#[test]
fn find_mise_configs_skips_hidden_and_dependency_dirs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".hidden")).unwrap();
std::fs::write(root.join(".hidden/.mise.toml"), "").unwrap();
std::fs::create_dir_all(root.join("node_modules/pkg")).unwrap();
std::fs::write(root.join("node_modules/pkg/.tool-versions"), "").unwrap();
std::fs::create_dir_all(root.join("vendor/lib")).unwrap();
std::fs::write(root.join("vendor/lib/.mise.toml"), "").unwrap();
let configs = find_mise_configs(root);
assert!(configs.is_empty(), "should skip hidden/dependency dirs, got: {configs:?}");
}
#[test]
fn find_mise_configs_respects_depth_limit() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("a/b/c")).unwrap();
std::fs::write(root.join("a/b/c/.mise.toml"), "").unwrap();
std::fs::create_dir_all(root.join("a/b/c/d")).unwrap();
std::fs::write(root.join("a/b/c/d/.mise.toml"), "").unwrap();
let configs = find_mise_configs(root);
let rel: Vec<_> = configs.iter().map(|p| p.strip_prefix(root).unwrap().to_string_lossy().to_string()).collect();
assert!(rel.contains(&"a/b/c/.mise.toml".to_string()));
assert!(!rel.contains(&"a/b/c/d/.mise.toml".to_string()), "should not scan beyond depth 3");
}
#[test]
fn should_warn_mise_shims_only_when_genuinely_missing() {
assert!(!should_warn_mise_shims(true, true), "activated => never warn");
assert!(!should_warn_mise_shims(true, false), "activated => never warn");
assert!(should_warn_mise_shims(false, true), "shims would help => warn");
assert!(!should_warn_mise_shims(false, false), "shims wouldn't help => no warn");
}
#[test]
fn do_copy_files_stops_on_cancel() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&ws).unwrap();
Command::new("git")
.args(["init", &path_str(&project)])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
std::fs::write(project.join(".gitignore"), "a.key\nb.key\nc.key\n").unwrap();
std::fs::write(project.join("a.key"), "a").unwrap();
std::fs::write(project.join("b.key"), "b").unwrap();
std::fs::write(project.join("c.key"), "c").unwrap();
Command::new("git")
.args(["-C", &path_str(&project), "add", ".gitignore"])
.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 cancelled = AtomicBool::new(true);
let silent = AtomicBool::new(false);
let files = enumerate_gitignored_files(&project).unwrap();
do_copy_files(&project, &ws, &files, &cancelled, &silent);
assert!(!ws.join("a.key").exists(), "no files should be copied when cancelled");
assert!(!ws.join("b.key").exists());
assert!(!ws.join("c.key").exists());
}
#[test]
fn do_copy_files_respects_silent_flag() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&ws).unwrap();
Command::new("git")
.args(["init", &path_str(&project)])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
std::fs::write(project.join(".gitignore"), "secret.key\n").unwrap();
std::fs::write(project.join("secret.key"), "value").unwrap();
Command::new("git")
.args(["-C", &path_str(&project), "add", ".gitignore"])
.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 cancelled = AtomicBool::new(false);
let silent = AtomicBool::new(true);
let files = enumerate_gitignored_files(&project).unwrap();
do_copy_files(&project, &ws, &files, &cancelled, &silent);
assert_eq!(std::fs::read_to_string(ws.join("secret.key")).unwrap(), "value");
}
#[test]
fn copy_gitignored_files_into_jj_workspace() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&ws).unwrap();
Command::new("git")
.args(["init", &path_str(&project)])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
std::fs::write(project.join(".gitignore"), "target/\n.env\n").unwrap();
std::fs::write(project.join("src.txt"), "code").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();
std::fs::create_dir_all(project.join("target/debug")).unwrap();
std::fs::write(project.join("target/debug/app"), "binary").unwrap();
std::fs::write(project.join(".env"), "SECRET=123").unwrap();
std::fs::write(ws.join("src.txt"), "code").unwrap();
std::fs::create_dir_all(ws.join(".jj/working_copy")).unwrap();
copy_gitignored_files(&project, &ws).unwrap();
assert_eq!(std::fs::read_to_string(ws.join("target/debug/app")).unwrap(), "binary");
assert_eq!(std::fs::read_to_string(ws.join(".env")).unwrap(), "SECRET=123");
}
}