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, run_git_utf8, Cmd};
use crate::claude_trust;
use crate::deps;
use crate::discover::{self, WsRef};
use crate::layout;
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];
#[derive(Default)]
pub struct WorkspaceOptions<'a> {
pub skip_copy_ignored: bool,
pub label: Option<&'a str>,
pub resume: Option<&'a str>,
pub config: Option<&'a str>,
}
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, 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 } = opts;
let ws = provision(project_dir, project_name, skip_copy_ignored, label, config, vcs)?;
let claude_session_id = attach(&ws, config, resume)?;
teardown(&ws, Some(&claude_session_id), 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 = mise_env(&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();
for p in provisioners {
if p.detect(&ws_dir) {
match p.setup(&ctx) {
Ok(s) => {
resources.extend(s.resources);
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(),
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, config: Option<&str>, resume: Option<&str>) -> Result<String> {
let mise_vars = mise_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;
let claude_session_id;
if let Some(prev_session_id) = resume {
migrate_claude_session(prev_session_id, &ws.ws_dir);
ws_layout = layout::resolve_resume_layout(config, prev_session_id)?;
claude_session_id = prev_session_id.to_string();
} else {
claude_session_id = generate_claude_session_id();
ws_layout = layout::resolve_workspace_layout(config, &claude_session_id)?;
}
session::launch(&tab_name, ws_layout.path(), &ws.ws_dir, &mise_vars)?;
Ok(claude_session_id)
}
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,
claude_session_id: Option<&str>,
save: SaveMode,
vcs: &dyn Vcs,
) -> Result<TeardownOutcome> {
eprintln!();
eprintln!("Cleaning up workspace {}...", ws.ws_id);
if let Some(sid) = claude_session_id {
eprintln!("Claude 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 Cmd::new("rm").args(["-rf", &path_str(&ws.ws_dir)]).spawn() {
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 layout_content = layout::read_config(config)?;
layout::validate_layout(&layout_content)?;
deps::check_all(&layout_content)?;
attach(&ws, config, 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",
})
}
Err(_) => Some(WorkspaceRow {
ws_id: dir_basename(),
name,
age_seconds,
project: None,
ws_dir: ws_dir.to_path_buf(),
status: "stale",
}),
}
}
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 mise_env(dir: &Path) -> HashMap<String, String> {
match Cmd::new("mise").arg("env").in_dir(dir).run() {
Ok(output) => parse_mise_env_output(&output.stdout_lossy()),
Err(_) => HashMap::new(),
}
}
fn parse_mise_env_output(output: &str) -> HashMap<String, String> {
output
.lines()
.filter_map(|line| {
let line = line.strip_prefix("export ")?;
let (key, value) = line.split_once('=')?;
let value = value.trim_matches('\'').trim_matches('"');
Some((key.to_string(), value.to_string()))
})
.collect()
}
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_claude_session_id() -> String {
uuid::Uuid::new_v4().to_string()
}
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())]),
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"));
}
#[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());
}
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"));
}
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())],
env_file: None,
})
}
}
#[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"));
}
#[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_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(),
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 claude_session_id_is_valid_uuid() {
let id = generate_claude_session_id();
assert!(uuid::Uuid::parse_str(&id).is_ok(), "should be a valid UUID: {id}");
}
#[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 parse_mise_env_output_extracts_vars() {
let output = "\
export PATH='/usr/local/bin:/usr/bin'
export RUBY_ROOT=/home/user/.mise/installs/ruby/4.0.1
export COMPOSER_HOME=\"/home/user/.composer\"
not an export line
";
let vars = parse_mise_env_output(output);
assert_eq!(vars.get("PATH").unwrap(), "/usr/local/bin:/usr/bin");
assert_eq!(vars.get("RUBY_ROOT").unwrap(), "/home/user/.mise/installs/ruby/4.0.1");
assert_eq!(vars.get("COMPOSER_HOME").unwrap(), "/home/user/.composer");
assert!(!vars.contains_key("not"));
}
#[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"
);
}
}