use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use vcs_runner::run_git_utf8;
#[derive(Debug, PartialEq, Eq)]
pub enum WsRef {
Cwd,
Path(PathBuf),
Token(String),
}
pub fn worktrees_dir() -> Result<PathBuf> {
Ok(crate::home::home_dir()?.join(".worktrees"))
}
pub fn classify_ref(reference: Option<&str>) -> WsRef {
match reference {
None => WsRef::Cwd,
Some(s) if looks_like_path(s) => WsRef::Path(PathBuf::from(s)),
Some(s) => WsRef::Token(s.to_string()),
}
}
fn looks_like_path(s: &str) -> bool {
s.contains('/') || s.starts_with('.') || s.starts_with('~')
}
pub fn ws_id_of(ws_dir: &Path, project_name: &str) -> Option<String> {
let base = ws_dir.file_name()?.to_str()?;
base.strip_prefix(&format!("{project_name}-")).map(String::from)
}
pub fn project_dir_of(ws_dir: &Path) -> Result<PathBuf> {
let common = run_git_utf8(ws_dir, &["rev-parse", "--path-format=absolute", "--git-common-dir"])
.with_context(|| format!("{} is not inside a git/jj worktree", ws_dir.display()))?;
let common = PathBuf::from(common.trim());
common
.parent()
.map(Path::to_path_buf)
.with_context(|| format!("git common dir {} has no parent", common.display()))
}
pub fn list_worktree_dirs() -> Result<Vec<PathBuf>> {
let root = worktrees_dir()?;
let entries = match std::fs::read_dir(&root) {
Ok(e) => e,
Err(_) => return Ok(Vec::new()),
};
let mut dirs: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
dirs.sort();
Ok(dirs)
}
pub fn assert_under_worktrees(ws_dir: &Path) -> Result<()> {
let root = std::fs::canonicalize(worktrees_dir()?)
.context("~/.worktrees does not exist")?;
let target = std::fs::canonicalize(ws_dir)
.with_context(|| format!("cannot resolve workspace path {}", ws_dir.display()))?;
if target == root || !target.starts_with(&root) {
bail!(
"refusing to operate on {} — not a workspace under {}",
target.display(),
root.display()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_ref_distinguishes_path_id_and_cwd() {
assert_eq!(classify_ref(None), WsRef::Cwd);
assert_eq!(classify_ref(Some("ws-abc123")), WsRef::Token("ws-abc123".into()));
assert_eq!(classify_ref(Some("fix-bug")), WsRef::Token("fix-bug".into()));
assert_eq!(classify_ref(Some("/abs/path")), WsRef::Path("/abs/path".into()));
assert_eq!(classify_ref(Some("./rel")), WsRef::Path("./rel".into()));
assert_eq!(classify_ref(Some("a/b")), WsRef::Path("a/b".into()));
assert_eq!(classify_ref(Some("~/w")), WsRef::Path("~/w".into()));
}
#[test]
fn ws_id_of_strips_project_prefix() {
assert_eq!(
ws_id_of(Path::new("/w/mbc-ws-abc123"), "mbc"),
Some("ws-abc123".to_string())
);
assert_eq!(
ws_id_of(Path::new("/w/mbc-ws-abc123-fix-bug"), "mbc"),
Some("ws-abc123-fix-bug".to_string())
);
}
#[test]
fn ws_id_of_none_when_prefix_absent() {
assert_eq!(ws_id_of(Path::new("/w/other-ws-abc"), "mbc"), None);
assert_eq!(ws_id_of(Path::new("/w/mb-ws-abc"), "mbc"), None);
}
#[test]
fn assert_under_worktrees_rejects_traversal_and_outside() {
assert!(assert_under_worktrees(Path::new("/etc")).is_err());
let escape = worktrees_dir().unwrap().join("..").join("..").join("etc");
assert!(assert_under_worktrees(&escape).is_err());
}
}