use crate::git::GitRepo;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
mod conflict;
pub use conflict::{
detect_goal_merge_conflicts, GoalMergeConflictCheckRequest, GoalMergeConflictEvidence,
};
const BRANCH_PREFIX: &str = "omk/goal";
const WORKTREE_PREFIX: &str = "goal";
const COMPONENT_MAX_CHARS: usize = 48;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GoalWorktreePlan {
pub goal_id: String,
pub task_id: String,
pub goal_component: String,
pub task_component: String,
pub branch_name: String,
pub worktree_name: String,
pub worktree_path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GoalWorktreeMaterializeRequest {
pub repo_dir: PathBuf,
pub worktrees_root: PathBuf,
pub goal_dir: Option<PathBuf>,
pub goal_id: String,
pub task_ids: Vec<String>,
pub dry_run: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GoalWorktreeMaterializeOutcome {
pub dry_run: bool,
pub plans: Vec<GoalWorktreePlan>,
}
pub fn plan_goal_worktree(
worktrees_root: impl AsRef<Path>,
goal_id: &str,
task_id: &str,
) -> Result<GoalWorktreePlan> {
let goal_component = normalize_identifier_component("goal id", goal_id)?;
let task_component = normalize_identifier_component("task id", task_id)?;
let fingerprint = stable_goal_task_fingerprint(goal_id, task_id);
let branch_name = format!("{BRANCH_PREFIX}/{goal_component}/{task_component}-{fingerprint}");
ensure_safe_branch_name(&branch_name).context("generated goal worktree branch is unsafe")?;
let worktree_name =
format!("{WORKTREE_PREFIX}-{goal_component}-{task_component}-{fingerprint}");
ensure_safe_path_component("worktree name", &worktree_name)?;
let worktree_path = worktrees_root.as_ref().join(&worktree_name);
Ok(GoalWorktreePlan {
goal_id: goal_id.to_string(),
task_id: task_id.to_string(),
goal_component,
task_component,
branch_name,
worktree_name,
worktree_path,
})
}
pub fn plan_goal_worktrees<I, S>(
worktrees_root: impl AsRef<Path>,
goal_id: &str,
task_ids: I,
) -> Result<Vec<GoalWorktreePlan>>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut branches = HashSet::new();
let mut worktree_names = HashSet::new();
let mut plans = Vec::new();
for task_id in task_ids {
let plan = plan_goal_worktree(worktrees_root.as_ref(), goal_id, task_id.as_ref())?;
if !branches.insert(plan.branch_name.clone()) {
anyhow::bail!("worktree plan collision for branch {}", plan.branch_name);
}
if !worktree_names.insert(plan.worktree_name.clone()) {
anyhow::bail!(
"worktree plan collision for worktree {}",
plan.worktree_name
);
}
plans.push(plan);
}
Ok(plans)
}
pub async fn materialize_goal_worktrees(
request: GoalWorktreeMaterializeRequest,
) -> Result<GoalWorktreeMaterializeOutcome> {
let plans = plan_goal_worktrees(
&request.worktrees_root,
&request.goal_id,
request.task_ids.iter().map(String::as_str),
)?;
ensure_git_worktree(&request.repo_dir).await?;
ensure_clean_git_worktree(&request.repo_dir).await?;
ensure_materialization_targets_are_available(&request.repo_dir, &plans).await?;
if let Some(goal_dir) = &request.goal_dir {
super::task_graph::ensure_worktree_delivery_targets(goal_dir, &plans).await?;
}
if request.dry_run {
return Ok(GoalWorktreeMaterializeOutcome {
dry_run: true,
plans,
});
}
tokio::fs::create_dir_all(&request.worktrees_root)
.await
.with_context(|| {
format!(
"Failed to create goal worktrees root: {}",
request.worktrees_root.display()
)
})?;
for plan in &plans {
create_git_worktree(&request.repo_dir, plan).await?;
if let Some(goal_dir) = &request.goal_dir {
super::task_graph::record_worktree_delivery_metadata(goal_dir, plan).await?;
}
}
Ok(GoalWorktreeMaterializeOutcome {
dry_run: false,
plans,
})
}
pub(crate) async fn is_git_worktree(repo_dir: &Path) -> Result<bool> {
Ok(GitRepo::open(repo_dir).is_ok())
}
async fn ensure_git_worktree(repo_dir: &Path) -> Result<()> {
if !is_git_worktree(repo_dir).await? {
anyhow::bail!(
"goal worktree materialization requires a git repository: {}",
repo_dir.display()
);
}
Ok(())
}
async fn ensure_clean_git_worktree(repo_dir: &Path) -> Result<()> {
let repo =
GitRepo::open(repo_dir).map_err(|e| anyhow::anyhow!("failed to open git repo: {e}"))?;
let files = repo
.changed_files()
.await
.map_err(|e| anyhow::anyhow!("git status failed: {e}"))?;
if !files.is_empty() {
let sample = files.iter().take(5).cloned().collect::<Vec<_>>().join("; ");
anyhow::bail!(
"goal worktree materialization requires a clean git worktree: {} has changes ({sample})",
repo_dir.display()
);
}
Ok(())
}
async fn ensure_materialization_targets_are_available(
repo_dir: &Path,
plans: &[GoalWorktreePlan],
) -> Result<()> {
for plan in plans {
if git_branch_exists(repo_dir, &plan.branch_name).await? {
anyhow::bail!("goal worktree branch already exists: {}", plan.branch_name);
}
if path_is_occupied(&plan.worktree_path)? {
anyhow::bail!(
"goal worktree path already exists: {}",
plan.worktree_path.display()
);
}
}
Ok(())
}
fn path_is_occupied(path: &Path) -> Result<bool> {
match std::fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => {
Err(error).with_context(|| format!("Failed to inspect path: {}", path.display()))
}
}
}
async fn git_branch_exists(repo_dir: &Path, branch_name: &str) -> Result<bool> {
let repo =
GitRepo::open(repo_dir).map_err(|e| anyhow::anyhow!("failed to open git repo: {e}"))?;
repo.branch_exists(branch_name)
.await
.map_err(|e| anyhow::anyhow!("git branch check failed: {e}"))
}
async fn create_git_worktree(repo_dir: &Path, plan: &GoalWorktreePlan) -> Result<()> {
let repo =
GitRepo::open(repo_dir).map_err(|e| anyhow::anyhow!("failed to open git repo: {e}"))?;
repo.branch_create(&plan.branch_name, Some("HEAD"))
.await
.map_err(|e| anyhow::anyhow!("git branch create failed: {e}"))?;
repo.worktree_add(&plan.worktree_path, &plan.branch_name)
.await
.map_err(|e| anyhow::anyhow!("git worktree add failed: {e}"))?;
Ok(())
}
pub(super) async fn remove_goal_worktree(repo_dir: &Path, worktree_path: &Path) -> Result<()> {
if !worktree_path.exists() {
return Ok(());
}
let repo =
GitRepo::open(repo_dir).map_err(|e| anyhow::anyhow!("failed to open git repo: {e}"))?;
repo.worktree_remove(worktree_path, true)
.await
.map_err(|e| anyhow::anyhow!("git worktree remove failed: {e}"))?;
Ok(())
}
pub(super) async fn remove_goal_worktrees(repo_dir: &Path, worktree_paths: &[PathBuf]) {
for path in worktree_paths {
if let Err(e) = remove_goal_worktree(repo_dir, path).await {
tracing::warn!(
error = %e,
worktree = %path.display(),
"Failed to remove goal worktree during cleanup"
);
}
}
}
fn normalize_identifier_component(label: &str, value: &str) -> Result<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
anyhow::bail!("{label} cannot be empty");
}
if trimmed.chars().any(char::is_control) {
anyhow::bail!("{label} contains control characters");
}
let mut normalized = String::new();
let mut last_was_dash = false;
for ch in trimmed.chars() {
if ch.is_ascii_alphanumeric() {
normalized.push(ch.to_ascii_lowercase());
last_was_dash = false;
} else if !last_was_dash {
normalized.push('-');
last_was_dash = true;
}
}
let normalized = normalized.trim_matches('-');
if normalized.is_empty() {
anyhow::bail!("{label} has no safe path or branch characters");
}
Ok(truncate_component(normalized))
}
fn truncate_component(component: &str) -> String {
let truncated: String = component.chars().take(COMPONENT_MAX_CHARS).collect();
truncated.trim_matches('-').to_string()
}
fn stable_goal_task_fingerprint(goal_id: &str, task_id: &str) -> String {
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = FNV_OFFSET;
for byte in b"omk-goal-worktree" {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
for byte in goal_id
.as_bytes()
.iter()
.chain([0xff].iter())
.chain(task_id.as_bytes())
{
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
format!("{hash:016x}")
}
fn ensure_safe_path_component(label: &str, component: &str) -> Result<()> {
if component.is_empty() || component == "." || component == ".." {
anyhow::bail!("{label} is not a safe path component");
}
if component.contains('/') || component.contains('\\') {
anyhow::bail!("{label} must not contain path separators");
}
if component.starts_with('.') || component.ends_with('.') {
anyhow::bail!("{label} must not start or end with a dot");
}
if component.chars().any(char::is_control) {
anyhow::bail!("{label} contains control characters");
}
Ok(())
}
fn ensure_safe_branch_name(branch: &str) -> Result<()> {
if branch.is_empty() || branch == "@" {
anyhow::bail!("branch name cannot be empty or @");
}
if branch.starts_with('/') || branch.ends_with('/') {
anyhow::bail!("branch name cannot start or end with /");
}
if branch.contains("//") || branch.contains("..") || branch.contains("@{") {
anyhow::bail!("branch name contains forbidden sequences");
}
if branch.ends_with('.') {
anyhow::bail!("branch name cannot end with dot");
}
if branch
.chars()
.any(|ch| ch.is_control() || matches!(ch, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\'))
{
anyhow::bail!("branch name contains forbidden characters");
}
for component in branch.split('/') {
if component.starts_with('.') || component.ends_with(".lock") {
anyhow::bail!("branch name contains forbidden component {component}");
}
}
Ok(())
}