ito-core 0.1.33

Core functionality and business logic for Ito
Documentation
//! Rules under the `worktrees/*` namespace.
//!
//! These rules only run when `worktrees.enabled = true`; otherwise the
//! engine reports them as skipped via [`super::list_active_rules`].
//!
//! Two rules live here:
//!
//! - `worktrees/no-write-on-control` — fails when the current branch matches
//!   `worktrees.default_branch` and the staged-files snapshot is non-empty.
//!   This is the "default-branch worktree" check from the spec; combined
//!   with the bare-control-siblings layout used by Ito itself this is also
//!   sufficient to catch "writing to main".
//! - `worktrees/layout-consistent` — emits warnings about layout drift
//!   relative to the resolved configuration.

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");

/// `worktrees/no-write-on-control` — flag staged commits in the control checkout.
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> {
        // Skip cheaply when there is nothing staged. The engine treats the
        // empty result as "rule passed".
        if ctx.staged.is_empty() {
            return Ok(Vec::new());
        }

        let Some(branch) = current_branch(ctx.runner, ctx.project_root)? else {
            // Detached HEAD or git failure — nothing to do.
            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])
    }
}

/// Read the current branch name via `git rev-parse --abbrev-ref HEAD`.
///
/// Returns `Ok(None)` for detached HEAD (`HEAD`) or when the underlying
/// command fails non-fatally (rule passes silently in those cases).
/// Returns an error only when `git` cannot be spawned at all.
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()))
    }
}

/// `worktrees/layout-consistent` — minimal layout drift checks.
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)
    }
}

/// True when `.gitignore` at `project_root` contains a line matching
/// `{dir_name}/` or `{dir_name}` (trimmed).
///
/// This is a deliberate substring match — it does not parse `.gitignore`
/// syntax (comments starting with `#`, negation patterns starting with
/// `!`, or glob patterns). For the WARNING-level
/// `worktrees/layout-consistent` rule that consumes it, false positives
/// (i.e. claiming an entry exists when a comment-only line matches) are
/// preferable to false negatives (silently letting drift through), and
/// the canonical entry is always a literal directory name.
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;