use std::path::Path;
use ito_config::types::{ItoConfig, WorktreeStrategy};
use crate::errors::CoreError;
use crate::process::{ProcessRequest, ProcessRunner};
use crate::validate::{ValidationIssue, error, warning, with_metadata, with_rule_id};
use super::rule::{Rule, RuleContext, RuleId, RuleSeverity};
const NO_WRITE_ON_CONTROL_ID: RuleId = RuleId::new("worktrees/no-write-on-control");
const LAYOUT_CONSISTENT_ID: RuleId = RuleId::new("worktrees/layout-consistent");
pub(crate) struct NoWriteOnControlRule;
impl Rule for NoWriteOnControlRule {
fn id(&self) -> RuleId {
NO_WRITE_ON_CONTROL_ID
}
fn severity(&self) -> RuleSeverity {
RuleSeverity::Error
}
fn description(&self) -> &'static str {
"Reject commits made directly in the control / default-branch worktree."
}
fn gate(&self) -> Option<&'static str> {
Some("worktrees.enabled == true")
}
fn is_active(&self, config: &ItoConfig) -> bool {
config.worktrees.enabled
}
fn check(&self, ctx: &RuleContext<'_>) -> Result<Vec<ValidationIssue>, CoreError> {
if ctx.staged.is_empty() {
return Ok(Vec::new());
}
let Some(branch) = current_branch(ctx.runner, ctx.project_root)? else {
return Ok(Vec::new());
};
let default_branch = ctx.config.worktrees.default_branch.trim();
if default_branch.is_empty() || branch != default_branch {
return Ok(Vec::new());
}
let issue = error(
".",
format!(
"Staged commits detected on the control / default-branch worktree (branch `{branch}`). \
Why: Ito's worktree workflow expects writes to live in change-specific worktrees so \
the control checkout stays clean and history stays separable per change. \
Fix: move the staged changes to a change worktree before committing.",
),
);
let issue = with_rule_id(issue, NO_WRITE_ON_CONTROL_ID.as_str());
let issue = with_metadata(
issue,
serde_json::json!({
"fix": "Run `ito worktree ensure --change <change-id>` and re-stage there.",
"default_branch": branch,
"staged_count": ctx.staged.len(),
}),
);
Ok(vec![issue])
}
}
fn current_branch(
runner: &dyn ProcessRunner,
project_root: &Path,
) -> Result<Option<String>, CoreError> {
let request = ProcessRequest::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(project_root);
let output = runner.run(&request).map_err(|err| {
CoreError::process(format!(
"Cannot determine the current git branch.\n\
Git command failed to run: {err}\n\
Fix: ensure git is installed and `{root}` is a git repository.",
root = project_root.display(),
))
})?;
if !output.success {
return Ok(None);
}
let trimmed = output.stdout.trim();
if trimmed.is_empty() || trimmed == "HEAD" {
Ok(None)
} else {
Ok(Some(trimmed.to_string()))
}
}
pub(crate) struct LayoutConsistentRule;
impl Rule for LayoutConsistentRule {
fn id(&self) -> RuleId {
LAYOUT_CONSISTENT_ID
}
fn severity(&self) -> RuleSeverity {
RuleSeverity::Warning
}
fn description(&self) -> &'static str {
"Worktree layout configuration matches the resolved strategy and gitignore."
}
fn gate(&self) -> Option<&'static str> {
Some("worktrees.enabled == true")
}
fn is_active(&self, config: &ItoConfig) -> bool {
config.worktrees.enabled
}
fn check(&self, ctx: &RuleContext<'_>) -> Result<Vec<ValidationIssue>, CoreError> {
let mut issues = Vec::new();
let dir_name = ctx.config.worktrees.layout.dir_name.trim();
if dir_name.is_empty() {
let issue = warning(
".ito/config.json",
"`worktrees.layout.dir_name` is empty; worktree directory placement is undefined.",
);
let issue = with_rule_id(issue, LAYOUT_CONSISTENT_ID.as_str());
issues.push(with_metadata(
issue,
serde_json::json!({
"fix": "Set `worktrees.layout.dir_name` to a non-empty directory name (default: `ito-worktrees`).",
}),
));
}
let strategy_requires_gitignore_entry = match ctx.config.worktrees.strategy {
WorktreeStrategy::CheckoutSubdir => true,
WorktreeStrategy::CheckoutSiblings | WorktreeStrategy::BareControlSiblings => false,
};
if strategy_requires_gitignore_entry
&& !dir_name.is_empty()
&& !gitignore_contains_dir(ctx.project_root, dir_name)?
{
let issue = warning(
".gitignore",
format!(
"`worktrees.strategy = checkout_subdir` but `.gitignore` does not list `{dir_name}/`. \
Untracked worktree files will appear in `git status`.",
),
);
let issue = with_rule_id(issue, LAYOUT_CONSISTENT_ID.as_str());
issues.push(with_metadata(
issue,
serde_json::json!({
"fix": format!("Append `{dir_name}/` to `.gitignore`."),
}),
));
}
Ok(issues)
}
}
fn gitignore_contains_dir(project_root: &Path, dir_name: &str) -> Result<bool, CoreError> {
let gitignore = project_root.join(".gitignore");
if !gitignore.exists() {
return Ok(false);
}
let content = std::fs::read_to_string(&gitignore).map_err(|e| {
CoreError::io(
format!(
"Cannot read `{path}` to check `worktrees/layout-consistent`.\n\
Why: filesystem error.\n\
Fix: confirm read permissions on `{path}`.",
path = gitignore.display(),
),
e,
)
})?;
let with_slash = format!("{dir_name}/");
Ok(content
.lines()
.map(str::trim)
.any(|line| line == dir_name || line == with_slash))
}
#[cfg(test)]
#[path = "worktrees_rules_tests.rs"]
mod worktrees_rules_tests;