use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::cli;
use crate::repo_lock::{self, GitDirectories, InvocationKind, LockDecision};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceEligibility {
MainWorktree,
LinkedWorktree,
}
pub fn classify_git_directories(dirs: &GitDirectories) -> WorkspaceEligibility {
if dirs.git_dir == dirs.common_dir {
WorkspaceEligibility::MainWorktree
} else {
WorkspaceEligibility::LinkedWorktree
}
}
pub fn parse_main_worktree(porcelain: &str) -> Option<PathBuf> {
porcelain
.lines()
.find_map(|line| line.strip_prefix("worktree "))
.map(str::trim)
.filter(|path| !path.is_empty())
.map(PathBuf::from)
}
pub fn linked_worktree_message(worktree: &Path, main_worktree: Option<&Path>) -> String {
let mut out = String::new();
out.push_str("conflux cannot start local orchestration from a linked git worktree\n");
out.push_str(&format!(" linked worktree: {}\n", worktree.display()));
match main_worktree {
Some(main) => out.push_str(&format!(" main worktree: {}\n", main.display())),
None => out.push_str(
" main worktree: unavailable (git worktree list --porcelain could not be read)\n",
),
}
out.push_str("Start Conflux from the main worktree instead. Nothing was changed: no worktree was created, redirected, pruned, or removed.");
out
}
fn read_main_worktree(workspace: &Path) -> Option<PathBuf> {
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(workspace)
.stdin(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let parsed = parse_main_worktree(&String::from_utf8_lossy(&output.stdout))?;
Some(std::fs::canonicalize(&parsed).unwrap_or(parsed))
}
fn main_worktree_for_diagnostic(workspace: &Path, common_dir: &Path) -> Option<PathBuf> {
read_main_worktree(workspace).or_else(|| common_dir.parent().map(Path::to_path_buf))
}
pub fn linked_worktree_error(workspace: &Path) -> Option<String> {
let dirs = repo_lock::discover_git_directories(workspace)?;
if classify_git_directories(&dirs) == WorkspaceEligibility::MainWorktree {
return None;
}
let main = main_worktree_for_diagnostic(workspace, &dirs.common_dir);
Some(linked_worktree_message(
&dirs.worktree_root,
main.as_deref(),
))
}
fn git_preflight_error() -> Option<String> {
if !cli::check_git_directory() {
return Some(
"conflux requires a git repository (.git directory not found): worktree \
orchestration is the only execution model, so run cflx from inside a git \
repository"
.to_string(),
);
}
if !cli::check_git_available() {
return Some(
"conflux requires the git command: install git, or make it available on PATH"
.to_string(),
);
}
None
}
pub fn owner_startup_error(kind: InvocationKind) -> Option<String> {
if matches!(repo_lock::classify_invocation(kind), LockDecision::Bypass) {
return None;
}
if let Some(message) = git_preflight_error() {
return Some(message);
}
let workspace = std::env::current_dir().ok()?;
linked_worktree_error(&workspace)
}
#[cfg(test)]
mod tests {
use super::*;
fn dirs(worktree_root: &str, git_dir: &str, common_dir: &str) -> GitDirectories {
GitDirectories {
worktree_root: PathBuf::from(worktree_root),
git_dir: PathBuf::from(git_dir),
common_dir: PathBuf::from(common_dir),
}
}
#[test]
fn a_plain_main_worktree_is_eligible() {
assert_eq!(
classify_git_directories(&dirs("/repo", "/repo/.git", "/repo/.git")),
WorkspaceEligibility::MainWorktree
);
}
#[test]
fn a_separate_git_directory_is_eligible() {
assert_eq!(
classify_git_directories(&dirs("/work", "/elsewhere/repo.git", "/elsewhere/repo.git")),
WorkspaceEligibility::MainWorktree
);
}
#[test]
fn a_linked_worktree_is_refused() {
assert_eq!(
classify_git_directories(&dirs("/repo/wt", "/repo/.git/worktrees/wt", "/repo/.git")),
WorkspaceEligibility::LinkedWorktree
);
}
#[test]
fn the_first_porcelain_record_names_the_main_worktree() {
let porcelain = "worktree /repo\nHEAD abc\nbranch refs/heads/main\n\n\
worktree /repo/wt\nHEAD def\nbranch refs/heads/linked\n";
assert_eq!(parse_main_worktree(porcelain), Some(PathBuf::from("/repo")));
}
#[test]
fn porcelain_output_without_a_usable_record_names_nothing() {
assert_eq!(parse_main_worktree(""), None);
assert_eq!(parse_main_worktree("worktree \n"), None);
assert_eq!(
parse_main_worktree("HEAD abc\nbranch refs/heads/main\n"),
None
);
}
#[test]
fn the_refusal_names_both_worktrees_and_where_to_start() {
let message = linked_worktree_message(Path::new("/repo/wt"), Some(Path::new("/repo")));
assert!(message.contains("/repo/wt"), "message={message}");
assert!(message.contains("/repo\n"), "message={message}");
assert!(message.contains("main worktree"), "message={message}");
assert!(
message.contains("Start Conflux from the main worktree"),
"message={message}"
);
}
#[test]
fn the_refusal_admits_an_unknown_main_worktree() {
let message = linked_worktree_message(Path::new("/repo/wt"), None);
assert!(message.contains("unavailable"), "message={message}");
assert!(
message.contains("Start Conflux from the main worktree"),
"message={message}"
);
}
#[test]
fn only_owner_entrypoints_are_preflighted() {
for kind in [
InvocationKind::DefaultTui,
InvocationKind::Tui,
InvocationKind::Run,
] {
assert!(matches!(
repo_lock::classify_invocation(kind),
LockDecision::Acquire(_)
));
}
assert_eq!(owner_startup_error(InvocationKind::Other), None);
}
}