#![allow(dead_code)]
use std::collections::BTreeSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::Duration;
use thiserror::Error;
use wait_timeout::ChildExt;
use crate::volume::{wt_dir, wt_for};
pub const PATH_LENGTH_WARN: usize = 80;
pub const PATH_LENGTH_ERROR: usize = 120;
pub const GIT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum RemoveMode {
Normal,
Force,
}
#[derive(Debug, Error)]
pub enum Error {
#[error(
"worktree already exists at {path}\n\
hint: `git worktree remove --force {}` to reclaim the slot",
path.display()
)]
AlreadyExists { path: PathBuf },
#[error(
"{path} is not a git repository\n\
hint: run `git init` in the volume root before `omne run`"
)]
NotAGitRepo { path: PathBuf },
#[error(
"invalid run_id {run_id:?}: must not be empty, contain path separators \
or null bytes, equal \".\" or \"..\", or start with '-'"
)]
InvalidRunId { run_id: String },
#[error(
"volume path {length} chars is at or above max {limit}: {}\n\
hint: shorten the volume root, or enable Windows Long Paths \
(`LongPathsEnabled=1` in HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem)",
path.display()
)]
PathTooLong {
path: PathBuf,
length: usize,
limit: usize,
},
#[error("failed to launch git: {source}")]
Io {
#[source]
source: std::io::Error,
},
#[error("git {args:?} did not exit within {elapsed:?}")]
GitTimeout {
args: Vec<String>,
elapsed: Duration,
},
#[error("git {args:?} failed: {stderr}")]
GitCommandFailed { args: Vec<String>, stderr: String },
}
#[derive(Debug, Default, Clone, Eq, PartialEq)]
pub struct WorktreeList {
pub paired: Vec<String>,
pub fs_only: Vec<String>,
pub git_only: Vec<String>,
}
pub fn preflight_volume_path_length(volume_root: &Path) -> Result<(), Error> {
let length = volume_root.as_os_str().len();
if length >= PATH_LENGTH_ERROR {
return Err(Error::PathTooLong {
path: volume_root.to_path_buf(),
length,
limit: PATH_LENGTH_ERROR,
});
}
if length >= PATH_LENGTH_WARN {
eprintln!(
"warning: volume path is {length} characters (>= {PATH_LENGTH_WARN}); \
git worktree may hit Windows MAX_PATH limits"
);
}
Ok(())
}
pub fn create(volume_root: &Path, run_id: &str) -> Result<PathBuf, Error> {
validate_run_id(run_id)?;
let wt_path = wt_for(volume_root, run_id);
if wt_path.exists() {
return Err(Error::AlreadyExists { path: wt_path });
}
let parent = wt_dir(volume_root);
std::fs::create_dir_all(&parent).map_err(|source| Error::Io { source })?;
let wt_path_str = wt_path.to_string_lossy();
let args: [&str; 4] = ["worktree", "add", "--detach", wt_path_str.as_ref()];
let output = run_git(volume_root, &args)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("already exists") {
return Err(Error::AlreadyExists { path: wt_path });
}
return Err(classify_git_error(volume_root, &args, &output.stderr));
}
Ok(wt_path)
}
pub fn remove(volume_root: &Path, run_id: &str, mode: RemoveMode) -> Result<(), Error> {
validate_run_id(run_id)?;
let wt_path = wt_for(volume_root, run_id);
let wt_path_str = wt_path.to_string_lossy();
let mut args: Vec<&str> = vec!["worktree", "remove"];
if matches!(mode, RemoveMode::Force) {
args.push("--force");
}
args.push(wt_path_str.as_ref());
let output = run_git(volume_root, &args)?;
if !output.status.success() {
return Err(classify_git_error(volume_root, &args, &output.stderr));
}
Ok(())
}
pub fn list(volume_root: &Path) -> Result<WorktreeList, Error> {
let args: [&str; 3] = ["worktree", "list", "--porcelain"];
let output = run_git(volume_root, &args)?;
if !output.status.success() {
return Err(classify_git_error(volume_root, &args, &output.stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let wt_root = wt_dir(volume_root);
let canonical_wt_root = if wt_root.is_dir() {
Some(
wt_root
.canonicalize()
.map_err(|source| Error::Io { source })?,
)
} else {
None
};
let mut git_ids: BTreeSet<String> = BTreeSet::new();
for line in stdout.lines() {
let Some(rest) = line.strip_prefix("worktree ") else {
continue;
};
let p = Path::new(rest.trim());
let Some(ref root) = canonical_wt_root else {
continue;
};
let Ok(canon_p) = p.canonicalize() else {
continue;
};
let Ok(rel) = canon_p.strip_prefix(root) else {
continue;
};
if let Some(first) = rel.components().next() {
git_ids.insert(first.as_os_str().to_string_lossy().into_owned());
}
}
let mut fs_ids: BTreeSet<String> = BTreeSet::new();
if wt_root.is_dir() {
let entries = std::fs::read_dir(&wt_root).map_err(|source| Error::Io { source })?;
for entry in entries {
let entry = entry.map_err(|source| Error::Io { source })?;
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
if is_dir {
fs_ids.insert(entry.file_name().to_string_lossy().into_owned());
}
}
}
let paired: Vec<String> = git_ids.intersection(&fs_ids).cloned().collect();
let fs_only: Vec<String> = fs_ids.difference(&git_ids).cloned().collect();
let git_only: Vec<String> = git_ids.difference(&fs_ids).cloned().collect();
Ok(WorktreeList {
paired,
fs_only,
git_only,
})
}
fn validate_run_id(run_id: &str) -> Result<(), Error> {
let invalid = run_id.is_empty()
|| run_id == "."
|| run_id == ".."
|| run_id.starts_with('-')
|| run_id.contains('/')
|| run_id.contains('\\')
|| run_id.contains('\0');
if invalid {
return Err(Error::InvalidRunId {
run_id: run_id.to_string(),
});
}
Ok(())
}
fn run_git(volume_root: &Path, args: &[&str]) -> Result<Output, Error> {
let mut child = Command::new("git")
.current_dir(volume_root)
.env("LC_ALL", "C")
.env("LANGUAGE", "")
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|source| Error::Io { source })?;
let mut stdout = Vec::new();
if let Some(mut s) = child.stdout.take() {
s.read_to_end(&mut stdout)
.map_err(|source| Error::Io { source })?;
}
let mut stderr = Vec::new();
if let Some(mut s) = child.stderr.take() {
s.read_to_end(&mut stderr)
.map_err(|source| Error::Io { source })?;
}
let status = match child
.wait_timeout(GIT_TIMEOUT)
.map_err(|source| Error::Io { source })?
{
Some(status) => status,
None => {
let _ = child.kill();
let _ = child.wait();
return Err(Error::GitTimeout {
args: args.iter().map(|s| s.to_string()).collect(),
elapsed: GIT_TIMEOUT,
});
}
};
Ok(Output {
status,
stdout,
stderr,
})
}
fn classify_git_error(volume_root: &Path, args: &[&str], stderr: &[u8]) -> Error {
let stderr = String::from_utf8_lossy(stderr).into_owned();
if stderr.contains("not a git repository") {
return Error::NotAGitRepo {
path: volume_root.to_path_buf(),
};
}
Error::GitCommandFailed {
args: args.iter().map(|s| s.to_string()).collect(),
stderr,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn preflight_passes_under_warn_threshold() {
let short = PathBuf::from("C:/o");
preflight_volume_path_length(&short).expect("under warn threshold should pass");
}
#[test]
fn preflight_warns_at_exact_warn_threshold() {
let at_warn = PathBuf::from("x".repeat(PATH_LENGTH_WARN));
preflight_volume_path_length(&at_warn).expect("at warn boundary must warn, not err");
}
#[test]
fn preflight_warns_between_thresholds_without_error() {
let mid = PathBuf::from("x".repeat(PATH_LENGTH_WARN + 10));
preflight_volume_path_length(&mid).expect("mid-range should warn, not error");
}
#[test]
fn preflight_errors_at_exact_error_threshold() {
let at_err = PathBuf::from("x".repeat(PATH_LENGTH_ERROR));
let err = preflight_volume_path_length(&at_err).unwrap_err();
assert!(
matches!(
err,
Error::PathTooLong { length, limit, .. }
if length == PATH_LENGTH_ERROR && limit == PATH_LENGTH_ERROR
),
"expected PathTooLong at exact threshold, got {err:?}"
);
}
#[test]
fn validate_run_id_accepts_typical_pipe_ulid_shape() {
validate_run_id("faber-01arz3ndektsv4rrffq69g5fav").expect("typical run_id should pass");
validate_run_id("x").expect("single-char run_id should pass");
}
#[test]
fn validate_run_id_rejects_traversal_and_separators() {
for bad in ["", ".", "..", "../escape", "a/b", "a\\b", "a\0b", "-flag"] {
let err = validate_run_id(bad).unwrap_err();
assert!(
matches!(err, Error::InvalidRunId { .. }),
"expected InvalidRunId for {bad:?}, got {err:?}"
);
}
}
#[test]
fn preflight_errors_above_error_threshold() {
let long = PathBuf::from("x".repeat(PATH_LENGTH_ERROR + 1));
let err = preflight_volume_path_length(&long).unwrap_err();
assert!(
matches!(
err,
Error::PathTooLong { length, limit, .. }
if length == PATH_LENGTH_ERROR + 1 && limit == PATH_LENGTH_ERROR
),
"expected PathTooLong, got {err:?}"
);
}
}