mod manifest;
mod merge;
mod python;
mod signing;
mod walkthrough;

use anyhow::{Context, Result};
use crossterm::style::Stylize;
use std::fs;
use std::io::{self, IsTerminal, Write};
use std::path::Path;

use crate::db::Database;
use merge::{write_mcp_json_merged, write_root_gitignore, write_settings_json_merged};
pub use python::detect_python_prefix;
use python::{install_cpitd, CpitdResult};
use signing::setup_driver_signing;
use walkthrough::{apply_tui_choices, run_tui_walkthrough, setup_shell_alias};

// Section markers re-exported from merge module (used by tests)

/// The placeholder used in the settings.json template for the Python invocation prefix.
const PYTHON_PREFIX_PLACEHOLDER: &str = "__PYTHON_PREFIX__";

// Embed hook files at compile time from resources/ (packaged with the crate)
const SETTINGS_JSON: &str = include_str!("../../../resources/claude/settings.json");
pub(crate) const PROMPT_GUARD_PY: &str =
    include_str!("../../../resources/claude/hooks/prompt-guard.py");
pub(crate) const POST_EDIT_CHECK_PY: &str =
    include_str!("../../../resources/claude/hooks/post-edit-check.py");
pub(crate) const SESSION_START_PY: &str =
    include_str!("../../../resources/claude/hooks/session-start.py");
pub(crate) const PRE_WEB_CHECK_PY: &str =
    include_str!("../../../resources/claude/hooks/pre-web-check.py");
pub(crate) const WORK_CHECK_PY: &str =
    include_str!("../../../resources/claude/hooks/work-check.py");
pub(crate) const CROSSLINK_CONFIG_PY: &str =
    include_str!("../../../resources/claude/hooks/crosslink_config.py");
pub(crate) const HEARTBEAT_PY: &str = include_str!("../../../resources/claude/hooks/heartbeat.py");

// Embed MCP servers
const SAFE_FETCH_SERVER_PY: &str =
    include_str!("../../../resources/claude/mcp/safe-fetch-server.py");
const KNOWLEDGE_SERVER_PY: &str = include_str!("../../../resources/claude/mcp/knowledge-server.py");
const AGENT_PROMPT_SERVER_PY: &str =
    include_str!("../../../resources/claude/mcp/agent-prompt-server.py");
const MCP_JSON: &str = include_str!("../../../resources/mcp.json");

// Embed slash commands — auto-generated by build.rs from resources/claude/commands/
include!(concat!(env!("OUT_DIR"), "/commands_gen.rs"));

// Hook configuration constant — imported from shared registry module
pub(crate) use crate::commands::config_registry::HOOK_CONFIG_JSON;

// Embed rule files — auto-generated by build.rs from resources/crosslink/rules/
// Generates RULE_* consts and RULE_FILES array for all .md and .txt files.
include!(concat!(env!("OUT_DIR"), "/rules_gen.rs"));

// Embed Claude skills — auto-generated by build.rs from resources/claude/skills/
// Generates SKILL_* consts and SKILL_FILES array. Each entry is
// (relative_path_within_skills_dir, content), e.g. ("architect/SKILL.md", "...").
include!(concat!(env!("OUT_DIR"), "/skills_gen.rs"));

/// The managed gitignore section content.
///
/// This block is placed between `GITIGNORE_SECTION_START` and `GITIGNORE_SECTION_END`
/// markers in the project root `.gitignore`. The markers make `crosslink init --force`
/// idempotent — re-running replaces the section in-place instead of appending duplicates.
use crate::commands::config_registry::{ConfigType, REGISTRY};
use std::collections::HashMap;

/// TUI walkthrough choices for `crosslink init` — registry-driven.
struct TuiChoices {
    values: HashMap<String, serde_json::Value>,
    install_alias: bool,
}

impl Default for TuiChoices {
    fn default() -> Self {
        let mut values = HashMap::new();
        // Default values from the embedded config
        let defaults: serde_json::Value =
            serde_json::from_str(HOOK_CONFIG_JSON).unwrap_or_default();
        for entry in REGISTRY {
            if matches!(
                entry.config_type,
                ConfigType::StringArray | ConfigType::Map | ConfigType::Integer
            ) {
                continue;
            }
            if let Some(v) = defaults.get(entry.key) {
                values.insert(entry.key.to_string(), v.clone());
            }
        }
        Self {
            values,
            install_alias: false,
        }
    }
}

// ── Styled inline output helper ──────────────────────────────────────────────

struct InitUI {
    is_tty: bool,
}

impl InitUI {
    fn new() -> Self {
        Self {
            is_tty: io::stdout().is_terminal(),
        }
    }

    fn banner(&self) {
        if self.is_tty {
            println!();
            println!("  {} {}", "crosslink".bold().cyan(), "init".dark_grey());
            println!("  {}", "─".repeat(40).dark_grey());
            println!();
        }
    }

    fn step_start(&self, label: &str) {
        if self.is_tty {
            print!("  {} {}... ", "●".cyan(), label);
            io::stdout().flush().ok();
        } else {
            print!("{label}... ");
        }
    }

    fn step_ok(&self, detail: Option<&str>) {
        if self.is_tty {
            match detail {
                Some(d) => println!("{} {}", "✓".green(), d.dark_grey()),
                None => println!("{}", "✓".green()),
            }
        } else {
            match detail {
                Some(d) => println!("done ({d})"),
                None => println!("done"),
            }
        }
    }

    fn step_created(&self, what: &str) {
        if self.is_tty {
            println!(
                "  {} {} {}",
                "✓".green(),
                "created".green(),
                what.dark_grey()
            );
        } else {
            println!("Created {what}");
        }
    }

    fn step_skip(&self, msg: &str) {
        if self.is_tty {
            println!("  {} {}", "–".dark_grey(), msg.dark_grey());
        } else {
            println!("{msg}");
        }
    }

    fn warn(&self, msg: &str) {
        if self.is_tty {
            println!("  {} {}", "⚠".yellow(), msg.yellow());
        } else {
            println!("Warning: {msg}");
        }
    }

    fn detail(&self, msg: &str) {
        if self.is_tty {
            println!("    {}", msg.dark_grey());
        } else {
            println!("  {msg}");
        }
    }

    fn success(&self) {
        if self.is_tty {
            println!();
            println!(
                "  {} {}",
                "✓".green().bold(),
                "Crosslink initialized successfully!".bold()
            );
            println!();
            println!(
                "  {} {} {}",
                "next".dark_grey(),
                "→".cyan(),
                "crosslink session start".white()
            );
            println!(
                "       {} {}",
                "→".cyan(),
                "crosslink create \"Task\"".white()
            );
            println!();
        } else {
            println!("Crosslink initialized successfully!");
            println!();
            println!("Crosslink tracks issues, comments, and sessions in .crosslink/issues.db.");
            println!("AI agents use it to coordinate work across sessions and worktrees.");
            println!();
            println!("Quick start:");
            println!("  crosslink create \"Task\"     # Create an issue");
            println!("  crosslink list              # See all issues");
            println!("  crosslink session start     # Start a work session");
            println!();
            println!("Multi-agent features (agents, signing, locks, containers) are optional");
            println!("and only needed when multiple AI agents collaborate on the same repo.");
        }
    }
}

/// Options for `crosslink init`.
pub struct InitOpts<'a> {
    pub force: bool,
    pub update: bool,
    pub dry_run: bool,
    pub no_prompt: bool,
    pub python_prefix: Option<&'a str>,
    pub skip_cpitd: bool,
    pub skip_signing: bool,
    pub signing_key: Option<&'a str>,
    pub reconfigure: bool,
    pub defaults: bool,
}

/// Ensure `repo_compact_id` exists in `.crosslink/repo-id`.
///
/// Generates a 4-char base62 identifier on first init and preserves it
/// on subsequent inits. Stored in a separate file (not hook-config.json)
/// to avoid triggering policy drift detection.
fn ensure_repo_compact_id(crosslink_dir: &Path) -> Result<()> {
    let id_path = crosslink_dir.join("repo-id");
    if id_path.exists() {
        return Ok(());
    }
    let id = crate::utils::generate_compact_id();
    fs::write(&id_path, &id).context("Failed to write repo-id")?;
    Ok(())
}

/// Read `repo_compact_id` from `.crosslink/repo-id`, or return a fallback.
pub fn read_repo_compact_id(crosslink_dir: &Path) -> String {
    let id_path = crosslink_dir.join("repo-id");
    if let Ok(id) = fs::read_to_string(&id_path) {
        let trimmed = id.trim();
        if !trimmed.is_empty() {
            return trimmed.to_string();
        }
    }
    // Fallback: generate from directory hash for consistent identity
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};
    let mut hasher = DefaultHasher::new();
    crosslink_dir.hash(&mut hasher);
    crate::utils::base62_encode_4(hasher.finish())
}

/// Lightweight driver identity setup for `crosslink init`.
///
/// Creates the agent config and generates an SSH key for hub-cache commit
/// signing, but does NOT publish keys to the hub or configure signing on the
/// cache worktree. Those heavier operations happen lazily on first
/// `crosslink sync` or `crosslink agent init`.
///
/// The identity is tagged `role: driver` so Claude Code hooks continue to
/// apply strict human-oriented rules to sessions in this repo, even though
/// `agent.json` exists for signing (see GH #566). `crosslink agent init` and
/// `crosslink agent bootstrap` override this to `role: agent`.
///
/// # Errors
///
/// Returns an error if agent config creation or SSH key generation fails.
fn init_agent_identity(crosslink_dir: &Path, agent_id: &str) -> Result<()> {
    // AgentConfig::init defaults to role=driver.
    let mut config = crate::identity::AgentConfig::init(crosslink_dir, agent_id, None)?;

    // GH#610: route through `host_crosslink_dir` for consistency with
    // agent init/bootstrap. `crosslink init` always runs in the main
    // repo, so this is a no-op in practice — but if any future
    // refactor moves this into a worktree context, key storage stays
    // pinned to the main repo and survives `git worktree remove`.
    let keys_dir = crate::signing::host_crosslink_dir(crosslink_dir).join("keys");
    match crate::signing::generate_agent_key(&keys_dir, agent_id, &config.machine_id) {
        Ok(keypair) => {
            config.ssh_key_path = Some(format!("keys/{agent_id}_ed25519"));
            config.ssh_fingerprint = Some(keypair.fingerprint);
            config.ssh_public_key = Some(keypair.public_key);

            let path = crosslink_dir.join("agent.json");
            let json = serde_json::to_string_pretty(&config)?;
            fs::write(&path, json)?;
        }
        Err(e) => {
            tracing::warn!("Could not generate agent SSH key: {e}");
        }
    }

    Ok(())
}

/// Detect the project's git remote and align `tracker_remote` in
/// `hook-config.json` with reality so the value is visible/editable
/// on disk rather than depending on the runtime fallback in
/// `sync::read_tracker_remote`.
///
/// The embedded template ships with `"tracker_remote": "origin"` as
/// the default. This function:
///
///   - leaves a manually-edited value alone (anything other than the
///     literal default `"origin"`) so `init --force` doesn't clobber
///     local customizations;
///   - leaves `"origin"` alone when the repo either has an `origin`
///     remote (configured or not, the value is the right placeholder)
///     OR has no remotes at all (then `origin` is the conventional
///     placeholder the user can fill in later);
///   - upgrades `"origin"` to the actual remote name when the repo has
///     a single non-origin remote, or multiple remotes none of which
///     are `origin` (alphabetically first wins — deterministic, no
///     prompt; the user can `crosslink config set tracker_remote
///     <name>` to override).
///
/// The byte-equality preservation in the no-change cases is what
/// keeps `crosslink workflow diff --check` happy: any re-serialize
/// would reorder keys (`serde_json`'s default `Map` is a `BTreeMap`)
/// and drift the file from the embedded template. See GH#586.
fn populate_tracker_remote(config_path: &Path, project_root: &Path) -> Result<()> {
    if !config_path.exists() {
        return Ok(());
    }

    let raw = fs::read_to_string(config_path)?;
    let mut config: serde_json::Value = serde_json::from_str(&raw)?;

    let current = config
        .get("tracker_remote")
        .and_then(|v| v.as_str())
        .map(String::from);

    // Anything other than the literal template default ("origin") or
    // the legacy corrupt placeholder "(text)" (GH#739) is a manual
    // edit — never touch it.
    if let Some(v) = &current {
        if v != "origin" && v != "(text)" {
            return Ok(());
        }
    }

    let detected = match detect_git_remotes(project_root).as_slice() {
        [] => None,
        [only] => Some(only.clone()),
        many if many.iter().any(|r| r == "origin") => Some("origin".to_string()),
        many => Some(many[0].clone()),
    };

    // Decide the new value (or no-op).
    let new_value = match (current.as_deref(), detected.as_deref()) {
        // Template-default + (no detected OR detected is also "origin"):
        // file already matches reality. No-op preserves byte-equality
        // with the embedded template so `workflow diff --check` passes.
        (Some("origin"), None | Some("origin")) => return Ok(()),
        // Template-default + repo has a real non-origin remote: upgrade.
        (Some("origin"), Some(other)) => other.to_string(),
        // Key absent (older config or hand-edit removed it) OR the
        // corrupt "(text)" placeholder from pre-#739 builds. Both are
        // treated as "no real value present": restore from detection,
        // or fall back to the template default. No byte-equality
        // preservation in the "(text)" case since the existing value
        // is wrong by definition.
        (Some("(text)") | None, Some(d)) => d.to_string(),
        (Some("(text)") | None, None) => "origin".to_string(),
        // Manual non-default — short-circuited at the top of the
        // function. This arm is unreachable but exhausts the match.
        (Some(_), _) => return Ok(()),
    };

    if let serde_json::Value::Object(map) = &mut config {
        map.insert(
            "tracker_remote".to_string(),
            serde_json::Value::String(new_value),
        );
    }

    let output = serde_json::to_string_pretty(&config)?;
    fs::write(config_path, format!("{output}\n"))?;
    Ok(())
}

/// List git remote names in `project_root` via `git remote`. Returns
/// an empty vector when git fails, when the directory isn't a repo, or
/// when no remotes are configured. Result is sorted to make the
/// "alphabetically first" tiebreaker in [`populate_tracker_remote`]
/// deterministic across hosts.
fn detect_git_remotes(project_root: &Path) -> Vec<String> {
    let output = std::process::Command::new("git")
        .current_dir(project_root)
        .args(["remote"])
        .output();
    let Ok(output) = output else {
        return Vec::new();
    };
    if !output.status.success() {
        return Vec::new();
    }
    let mut remotes: Vec<String> = String::from_utf8_lossy(&output.stdout)
        .lines()
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(String::from)
        .collect();
    remotes.sort();
    remotes
}

/// Detect project lint/test commands and populate `agent_overrides` in
/// hook-config.json so kickoff agents can self-validate their work (#495).
///
/// Only populates empty arrays — preserves any manually configured commands.
fn populate_agent_tool_commands(config_path: &Path, project_root: &Path) -> Result<()> {
    if !config_path.exists() {
        return Ok(());
    }

    let raw = fs::read_to_string(config_path)?;
    let mut config: serde_json::Value = serde_json::from_str(&raw)?;

    let Some(serde_json::Value::Object(overrides)) = config.get_mut("agent_overrides") else {
        return Ok(());
    };

    // Only populate if arrays are empty (don't overwrite manual config)
    let lint_empty = overrides
        .get("agent_lint_commands")
        .and_then(|v| v.as_array())
        .is_none_or(Vec::is_empty);
    let test_empty = overrides
        .get("agent_test_commands")
        .and_then(|v| v.as_array())
        .is_none_or(Vec::is_empty);

    if !lint_empty && !test_empty {
        return Ok(()); // Both already configured
    }

    let conv = super::kickoff::detect_conventions(project_root);

    let changed = if lint_empty && !conv.lint_commands.is_empty() {
        overrides.insert(
            "agent_lint_commands".to_string(),
            serde_json::json!(conv.lint_commands),
        );
        true
    } else {
        false
    };

    let changed = if test_empty {
        conv.test_command.as_ref().map_or(changed, |test_cmd| {
            overrides.insert(
                "agent_test_commands".to_string(),
                serde_json::json!([test_cmd]),
            );
            true
        })
    } else {
        changed
    };

    if changed {
        let output = serde_json::to_string_pretty(&config)?;
        fs::write(config_path, format!("{output}\n"))?;
    }

    Ok(())
}

/// Collect all managed files as `(relative_path, template_content)` pairs.
///
/// For `settings.json` the content is the template *after* `__PYTHON_PREFIX__`
/// substitution but *before* the `allowedTools` merge — this ensures user
/// tool additions don't produce false "user-modified" signals in the manifest.
fn managed_files(python_prefix: &str) -> Vec<(String, String)> {
    let mut files: Vec<(String, String)> = vec![
        (
            ".claude/hooks/prompt-guard.py".into(),
            PROMPT_GUARD_PY.into(),
        ),
        (
            ".claude/hooks/post-edit-check.py".into(),
            POST_EDIT_CHECK_PY.into(),
        ),
        (
            ".claude/hooks/session-start.py".into(),
            SESSION_START_PY.into(),
        ),
        (
            ".claude/hooks/pre-web-check.py".into(),
            PRE_WEB_CHECK_PY.into(),
        ),
        (".claude/hooks/work-check.py".into(), WORK_CHECK_PY.into()),
        (
            ".claude/hooks/crosslink_config.py".into(),
            CROSSLINK_CONFIG_PY.into(),
        ),
        (".claude/hooks/heartbeat.py".into(), HEARTBEAT_PY.into()),
        (
            ".claude/mcp/safe-fetch-server.py".into(),
            SAFE_FETCH_SERVER_PY.into(),
        ),
        (
            ".claude/mcp/knowledge-server.py".into(),
            KNOWLEDGE_SERVER_PY.into(),
        ),
        (
            ".claude/mcp/agent-prompt-server.py".into(),
            AGENT_PROMPT_SERVER_PY.into(),
        ),
    ];

    // Command files (auto-discovered by build.rs)
    for (filename, content) in COMMAND_FILES {
        files.push((format!(".claude/commands/{filename}"), content.to_string()));
    }

    // Rule files (auto-discovered by build.rs)
    for (filename, content) in RULE_FILES {
        files.push((format!(".crosslink/rules/{filename}"), content.to_string()));
    }

    // Claude skill files (auto-discovered by build.rs).
    // rel_path includes the skill subdirectory, e.g. "architect/SKILL.md" or
    // "rust-gpu-discipline/anti-patterns.md", so the deploy target preserves
    // the per-skill folder structure under .claude/skills/.
    for (rel_path, content) in SKILL_FILES {
        files.push((format!(".claude/skills/{rel_path}"), content.to_string()));
    }

    // Settings.json — template after prefix substitution, before merge
    let settings_template = SETTINGS_JSON.replace(PYTHON_PREFIX_PLACEHOLDER, python_prefix);
    files.push((".claude/settings.json".into(), settings_template));

    files
}

/// Run the `--update` flow: manifest-tracked safe upgrade of managed files.
fn run_update(path: &Path, opts: &InitOpts<'_>) -> Result<()> {
    use manifest::{
        build_manifest, classify_update, read_manifest, sha256_file, sha256_hex, write_manifest,
        UpdateAction,
    };

    let crosslink_dir = path.join(".crosslink");
    let ui = InitUI::new();

    if !crosslink_dir.exists() || !path.join(".claude").exists() {
        anyhow::bail!(
            "Project not initialized. Run `crosslink init` first, then use `--update` for upgrades."
        );
    }

    // Detect Python prefix (needed for settings.json template)
    let prefix = opts.python_prefix.map_or_else(
        || detect_python_prefix(path),
        std::string::ToString::to_string,
    );

    let template_files = managed_files(&prefix);
    let old_manifest = read_manifest(&crosslink_dir);

    let manifest_missing = old_manifest.is_none();
    if manifest_missing {
        ui.warn(
            "No init-manifest.json found — treating all managed files as potentially modified.",
        );
        ui.detail("This is expected on first upgrade from a pre-manifest crosslink version.");
        ui.detail("Use `crosslink init --force` instead to overwrite all managed files.");
        println!();
    }

    ui.banner();

    // Build a lookup from the old manifest
    let old_files = old_manifest
        .as_ref()
        .map(|m| &m.files)
        .cloned()
        .unwrap_or_default();

    let mut auto_updated: Vec<String> = Vec::new();
    let mut up_to_date: Vec<String> = Vec::new();
    let mut template_unchanged: Vec<String> = Vec::new();
    let mut conflicts: Vec<String> = Vec::new();
    let mut deleted: Vec<String> = Vec::new();
    let mut new_files: Vec<String> = Vec::new();

    // Classify each managed file
    for (rel_path, template_content) in &template_files {
        let abs_path = path.join(rel_path);
        let new_template_hash = sha256_hex(template_content);

        match old_files.get(rel_path) {
            Some(entry) => {
                let current_hash = sha256_file(&abs_path)?;
                let action =
                    classify_update(&entry.sha256, current_hash.as_deref(), &new_template_hash);

                match action {
                    UpdateAction::UpToDate => up_to_date.push(rel_path.clone()),
                    UpdateAction::AutoUpdate => auto_updated.push(rel_path.clone()),
                    UpdateAction::TemplateUnchanged => {
                        template_unchanged.push(rel_path.clone());
                    }
                    UpdateAction::Conflict => conflicts.push(rel_path.clone()),
                    UpdateAction::Deleted => deleted.push(rel_path.clone()),
                    UpdateAction::NewFile => unreachable!(),
                }
            }
            None => {
                // File not in old manifest — it's a newly added template file
                new_files.push(rel_path.clone());
            }
        }
    }

    // ── Report ──────────────────────────────────────────────────────────
    let total_changes = auto_updated.len() + new_files.len();
    let has_issues = !conflicts.is_empty() || !deleted.is_empty();

    if total_changes == 0 && !has_issues {
        ui.step_skip("All managed files are up to date.");
        return Ok(());
    }

    if !auto_updated.is_empty() {
        ui.step_start(&format!(
            "{} file{} to auto-update",
            auto_updated.len(),
            if auto_updated.len() == 1 { "" } else { "s" }
        ));
        println!();
        for f in &auto_updated {
            ui.detail(f);
        }
    }

    if !new_files.is_empty() {
        ui.step_start(&format!(
            "{} new file{} to create",
            new_files.len(),
            if new_files.len() == 1 { "" } else { "s" }
        ));
        println!();
        for f in &new_files {
            ui.detail(f);
        }
    }

    if !conflicts.is_empty() {
        ui.warn(&format!(
            "{} file{} modified by both user and template — {}",
            conflicts.len(),
            if conflicts.len() == 1 { "" } else { "s" },
            if opts.no_prompt {
                "skipping (--no-prompt)"
            } else {
                "will prompt"
            }
        ));
        for f in &conflicts {
            ui.detail(f);
        }
    }

    for f in &deleted {
        ui.detail(&format!(
            "{f} — deleted by user, skipping (will not recreate)"
        ));
    }

    if !template_unchanged.is_empty() || !up_to_date.is_empty() {
        let skip_count = template_unchanged.len() + up_to_date.len();
        ui.step_skip(&format!(
            "{skip_count} file{} already up to date",
            if skip_count == 1 { "" } else { "s" }
        ));
    }

    // ── Dry run: stop here ──────────────────────────────────────────────
    if opts.dry_run {
        println!();
        ui.detail("Dry run — no files were modified.");
        return Ok(());
    }

    // ── Apply changes ───────────────────────────────────────────────────
    let template_map: std::collections::HashMap<&str, &str> = template_files
        .iter()
        .map(|(k, v)| (k.as_str(), v.as_str()))
        .collect();

    // Auto-update files (user never touched them)
    for rel_path in &auto_updated {
        let abs_path = path.join(rel_path);
        let content = template_map[rel_path.as_str()];

        if rel_path == ".claude/settings.json" {
            // settings.json: re-run the merge to preserve user allowedTools
            write_settings_json_merged(&abs_path, &prefix)?;
        } else {
            // Ensure parent directory exists
            if let Some(parent) = abs_path.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::write(&abs_path, content).with_context(|| format!("Failed to write {rel_path}"))?;
        }
    }

    // Create new files (added in newer template set)
    for rel_path in &new_files {
        let abs_path = path.join(rel_path);
        let content = template_map[rel_path.as_str()];

        if rel_path == ".claude/settings.json" {
            write_settings_json_merged(&abs_path, &prefix)?;
        } else {
            if let Some(parent) = abs_path.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::write(&abs_path, content).with_context(|| format!("Failed to write {rel_path}"))?;
        }
    }

    // Handle conflicts (prompt or skip)
    let mut conflict_accepted: Vec<String> = Vec::new();
    if !opts.no_prompt {
        let is_tty = io::stdin().is_terminal();
        for rel_path in &conflicts {
            if !is_tty {
                // Non-interactive: skip like --no-prompt
                ui.detail(&format!("Skipping {rel_path} (non-interactive terminal)"));
                continue;
            }

            print!("  Overwrite {rel_path} with new template? (user changes will be lost) [y/N] ");
            io::stdout().flush().ok();

            let mut answer = String::new();
            io::stdin().read_line(&mut answer).ok();

            if answer.trim().eq_ignore_ascii_case("y") {
                let abs_path = path.join(rel_path);
                let content = template_map[rel_path.as_str()];
                if rel_path == ".claude/settings.json" {
                    write_settings_json_merged(&abs_path, &prefix)?;
                } else {
                    fs::write(&abs_path, content)
                        .with_context(|| format!("Failed to write {rel_path}"))?;
                }
                conflict_accepted.push(rel_path.clone());
            } else {
                ui.detail(&format!("Keeping user version of {rel_path}"));
            }
        }
    }

    // ── Write updated manifest ──────────────────────────────────────────
    // Build new manifest. For files we wrote, use the new template hash.
    // For files we skipped (user-modified, deleted), keep the old manifest entry
    // so future updates can still detect changes correctly.
    let new_full_manifest = build_manifest(&template_files);
    let mut final_manifest = new_full_manifest;

    // For files where the user kept their version (conflict not accepted),
    // preserve the old manifest hash so the conflict surfaces again next time.
    for rel_path in &conflicts {
        if !conflict_accepted.contains(rel_path) {
            if let Some(old_entry) = old_files.get(rel_path) {
                final_manifest
                    .files
                    .insert(rel_path.clone(), old_entry.clone());
            }
        }
    }

    // For deleted files, remove from manifest entirely
    for rel_path in &deleted {
        final_manifest.files.remove(rel_path);
    }

    // For template-unchanged user-modified files, preserve old manifest entry
    for rel_path in &template_unchanged {
        if let Some(old_entry) = old_files.get(rel_path) {
            final_manifest
                .files
                .insert(rel_path.clone(), old_entry.clone());
        }
    }

    write_manifest(&crosslink_dir, &final_manifest)?;

    // ── Summary ─────────────────────────────────────────────────────────
    let total_written = auto_updated.len() + new_files.len() + conflict_accepted.len();
    if total_written > 0 {
        ui.step_ok(Some(&format!(
            "{total_written} file{} updated",
            if total_written == 1 { "" } else { "s" }
        )));
    }

    Ok(())
}

pub fn run(path: &Path, opts: &InitOpts<'_>) -> Result<()> {
    // Dispatch to update flow if --update is set
    if opts.update {
        return run_update(path, opts);
    }

    let force = opts.force;
    let python_prefix = opts.python_prefix;
    let skip_cpitd = opts.skip_cpitd;
    let skip_signing = opts.skip_signing;
    let signing_key = opts.signing_key;
    let reconfigure = opts.reconfigure;
    let defaults = opts.defaults;
    let crosslink_dir = path.join(".crosslink");
    let claude_dir = path.join(".claude");
    let hooks_dir = claude_dir.join("hooks");

    let ui = InitUI::new();

    // Pre-flight: verify git repo exists and has at least one commit (#401).
    // Crosslink uses git worktrees for hub/knowledge branches, which require
    // a functioning repo with commit history.
    let git_dir = path.join(".git");
    if !git_dir.exists() {
        anyhow::bail!(
            "No git repository found at {}.\n\
             Run `git init` and create an initial commit before running `crosslink init`.",
            path.display()
        );
    }
    let has_commits = std::process::Command::new("git")
        .current_dir(path)
        .args(["rev-parse", "HEAD"])
        .output()
        .is_ok_and(|o| o.status.success());
    if !has_commits {
        anyhow::bail!(
            "Git repository has no commits.\n\
             Create an initial commit before running `crosslink init`:\n\
             \n  git add .\n  git commit -m \"Initial commit\""
        );
    }

    // Check if already initialized
    let crosslink_exists = crosslink_dir.exists();
    let claude_exists = claude_dir.exists();

    if crosslink_exists && claude_exists && !force && !reconfigure {
        ui.step_skip("Already initialized");
        ui.detail("Use --force to update hooks to latest version.");
        ui.detail("Use --reconfigure to re-run the setup walkthrough.");
        return Ok(());
    }

    // ── TUI walkthrough FIRST — before any filesystem changes ────────────
    // If the user presses Esc, we bail before creating anything.
    let config_path = crosslink_dir.join("hook-config.json");
    let config_exists = config_path.exists();
    let should_run_tui = !defaults && (!config_exists || force || reconfigure);

    let tui_result = if should_run_tui {
        let base_config: serde_json::Value = if config_exists && reconfigure {
            let raw = fs::read_to_string(&config_path)
                .context("Failed to read existing hook-config.json")?;
            serde_json::from_str(&raw).context("hook-config.json contains invalid JSON")?
        } else {
            serde_json::from_str(HOOK_CONFIG_JSON)
                .context("Embedded hook-config.json is invalid")?
        };

        let existing_ref = if config_exists {
            Some(&base_config as &serde_json::Value)
        } else {
            None
        };
        let choices = run_tui_walkthrough(existing_ref)?;
        Some((base_config, choices))
    } else {
        None
    };

    // ── All filesystem changes happen below this point ───────────────────

    // Dry-run guard: preview what --force would write, then exit without
    // touching the filesystem (mirrors the --update --dry-run behaviour).
    if opts.dry_run {
        let ui = InitUI::new();
        ui.banner();

        let prefix = python_prefix.map_or_else(
            || detect_python_prefix(path),
            std::string::ToString::to_string,
        );
        let files = managed_files(&prefix);

        let mut would_write: Vec<&str> = Vec::new();
        let mut would_create: Vec<&str> = Vec::new();
        for (rel_path, _) in &files {
            if path.join(rel_path).exists() {
                would_write.push(rel_path);
            } else {
                would_create.push(rel_path);
            }
        }

        // Always includes non-managed files written by init
        let extra = [".mcp.json", ".crosslink/hook-config.json", ".gitignore"];
        for f in &extra {
            if path.join(f).exists() {
                would_write.push(f);
            } else {
                would_create.push(f);
            }
        }

        if !would_write.is_empty() {
            ui.step_start(&format!(
                "{} file{} to overwrite",
                would_write.len(),
                if would_write.len() == 1 { "" } else { "s" }
            ));
            println!();
            for f in &would_write {
                ui.detail(f);
            }
        }
        if !would_create.is_empty() {
            ui.step_start(&format!(
                "{} new file{} to create",
                would_create.len(),
                if would_create.len() == 1 { "" } else { "s" }
            ));
            println!();
            for f in &would_create {
                ui.detail(f);
            }
        }

        println!();
        ui.detail("Dry run — no files were modified.");
        return Ok(());
    }

    ui.banner();

    let rules_dir = crosslink_dir.join("rules");

    // Create .crosslink directory and database
    if !crosslink_exists {
        ui.step_start("Initializing database");
        fs::create_dir_all(&crosslink_dir).context("Failed to create .crosslink directory")?;
        let db_path = crosslink_dir.join("issues.db");
        Database::open(&db_path)?;
        ui.step_ok(None);
    }

    // Write hook config
    let tui_choices = match tui_result {
        Some((mut config, choices)) => {
            apply_tui_choices(&mut config, &choices)?;
            let output = serde_json::to_string_pretty(&config)
                .context("Failed to serialize hook-config.json")?;
            fs::write(&config_path, format!("{output}\n"))
                .context("Failed to write hook-config.json")?;
            ui.step_created("hook-config.json");
            Some(choices)
        }
        None if !config_exists || force => {
            fs::write(&config_path, HOOK_CONFIG_JSON)
                .context("Failed to write hook-config.json")?;
            ui.step_created("hook-config.json");
            None
        }
        None => None,
    };

    // Ensure repo_compact_id exists in hook-config.json (idempotent)
    ensure_repo_compact_id(&crosslink_dir)?;

    // Auto-detect lint/test commands for agent overrides (#495)
    populate_agent_tool_commands(&config_path, path)?;

    // Detect the git remote and write `tracker_remote` so the value is
    // visible on disk rather than relying on the runtime fallback
    // in `sync::read_tracker_remote`. Idempotent — preserves manual
    // values across `init --force`. See GH#586.
    populate_tracker_remote(&config_path, path)?;

    // Write .crosslink/.gitignore for multi-agent files
    let crosslink_gitignore = crosslink_dir.join(".gitignore");
    if !crosslink_gitignore.exists() || force {
        fs::write(
            &crosslink_gitignore,
            "# Multi-agent collaboration (machine-local)\n\
             agent.json\n\
             repo-id\n\
             .hub-cache/\n\
             .knowledge-cache/\n\
             keys/\n\
             integrations/\n\
             \n\
             # Machine-local overrides\n\
             hook-config.local.json\n\
             rules.local/\n",
        )
        .context("Failed to write .crosslink/.gitignore")?;
    }

    // Write/update managed section in root .gitignore
    ui.step_start("Configuring .gitignore");
    write_root_gitignore(path).context("Failed to update root .gitignore")?;
    ui.step_ok(None);

    // Create or update rules directory
    let rules_exist = rules_dir.exists();
    if !rules_exist || force {
        ui.step_start("Deploying rules");
        fs::create_dir_all(&rules_dir).context("Failed to create .crosslink/rules directory")?;

        for (filename, content) in RULE_FILES {
            fs::write(rules_dir.join(filename), content)
                .with_context(|| format!("Failed to write {filename}"))?;
        }

        if force && rules_exist {
            ui.step_ok(Some("updated"));
        } else {
            ui.step_ok(Some(&format!("{} files", RULE_FILES.len())));
        }
    }

    // Create rules.local directory for machine-local rule overrides
    let rules_local_dir = crosslink_dir.join("rules.local");
    if !rules_local_dir.exists() {
        fs::create_dir_all(&rules_local_dir)
            .context("Failed to create .crosslink/rules.local directory")?;
    }

    // Detect or use provided Python prefix (needed for settings.json and cpitd install)
    let prefix = python_prefix.map_or_else(
        || detect_python_prefix(path),
        std::string::ToString::to_string,
    );

    // Create .claude directory and hooks (or update if force)
    if !claude_exists || force {
        ui.step_start("Setting up Claude Code hooks");
        fs::create_dir_all(&hooks_dir).context("Failed to create .claude/hooks directory")?;

        write_settings_json_merged(&claude_dir.join("settings.json"), &prefix)
            .context("Failed to write settings.json")?;

        fs::write(hooks_dir.join("prompt-guard.py"), PROMPT_GUARD_PY)
            .context("Failed to write prompt-guard.py")?;
        fs::write(hooks_dir.join("post-edit-check.py"), POST_EDIT_CHECK_PY)
            .context("Failed to write post-edit-check.py")?;
        fs::write(hooks_dir.join("session-start.py"), SESSION_START_PY)
            .context("Failed to write session-start.py")?;
        fs::write(hooks_dir.join("pre-web-check.py"), PRE_WEB_CHECK_PY)
            .context("Failed to write pre-web-check.py")?;
        fs::write(hooks_dir.join("work-check.py"), WORK_CHECK_PY)
            .context("Failed to write work-check.py")?;
        fs::write(hooks_dir.join("crosslink_config.py"), CROSSLINK_CONFIG_PY)
            .context("Failed to write crosslink_config.py")?;
        fs::write(hooks_dir.join("heartbeat.py"), HEARTBEAT_PY)
            .context("Failed to write heartbeat.py")?;

        let mcp_dir = claude_dir.join("mcp");
        fs::create_dir_all(&mcp_dir).context("Failed to create .claude/mcp directory")?;
        fs::write(mcp_dir.join("safe-fetch-server.py"), SAFE_FETCH_SERVER_PY)
            .context("Failed to write safe-fetch-server.py")?;
        fs::write(mcp_dir.join("knowledge-server.py"), KNOWLEDGE_SERVER_PY)
            .context("Failed to write knowledge-server.py")?;
        fs::write(
            mcp_dir.join("agent-prompt-server.py"),
            AGENT_PROMPT_SERVER_PY,
        )
        .context("Failed to write agent-prompt-server.py")?;

        let commands_dir = claude_dir.join("commands");
        fs::create_dir_all(&commands_dir).context("Failed to create .claude/commands directory")?;
        for (filename, content) in COMMAND_FILES {
            fs::write(commands_dir.join(filename), content)
                .with_context(|| format!("Failed to write {filename}"))?;
        }

        // Deploy bundled Claude skills under .claude/skills/<skill>/.
        // SKILL_FILES entries are (relative_path, content) where relative_path
        // includes the per-skill subdirectory (e.g. "architect/SKILL.md").
        let skills_dir = claude_dir.join("skills");
        fs::create_dir_all(&skills_dir).context("Failed to create .claude/skills directory")?;
        for (rel_path, content) in SKILL_FILES {
            let target = skills_dir.join(rel_path);
            if let Some(parent) = target.parent() {
                fs::create_dir_all(parent)
                    .with_context(|| format!("Failed to create skill directory for {rel_path}"))?;
            }
            fs::write(&target, content)
                .with_context(|| format!("Failed to write skill file {rel_path}"))?;
        }

        let warnings =
            write_mcp_json_merged(&path.join(".mcp.json")).context("Failed to write .mcp.json")?;
        for warning in &warnings {
            ui.warn(warning);
        }

        if force && claude_exists {
            ui.step_ok(Some("updated"));
        } else {
            ui.step_ok(None);
        }
    }

    // Write init manifest after all managed file writes succeed.
    // This enables future `--update` runs to perform three-way merges.
    {
        let manifest_files = managed_files(&prefix);
        let m = manifest::build_manifest(&manifest_files);
        manifest::write_manifest(&crosslink_dir, &m)
            .context("Failed to write init-manifest.json")?;
    }

    // Auto-install cpitd unless skipped
    if !skip_cpitd {
        ui.step_start("Checking cpitd");
        match install_cpitd(&prefix) {
            Ok(CpitdResult::InstalledFromPypi) => ui.step_ok(Some("installed")),
            Ok(CpitdResult::InstalledFromSource) => ui.step_ok(Some("installed from source")),
            Ok(CpitdResult::AlreadyInstalled) => ui.step_ok(Some("already installed")),
            Err(e) => {
                // Finish the step_start line, then show warning below
                println!();
                ui.warn(&format!("Could not auto-install cpitd: {e}"));
                if e.externally_managed {
                    // PEP 668: the system Python is externally managed, so plain
                    // `pip install` (and `pip install --user`) are refused. Give
                    // the specific, working paths instead of a useless suggestion.
                    ui.detail(
                        "This Python is externally managed (PEP 668), so `pip install` is blocked.",
                    );
                    ui.detail("To install cpitd, pick one of:");
                    ui.detail("  1. Install pipx, then cpitd into its own isolated env:");
                    ui.detail("       apt install pipx   (or: brew install pipx)");
                    ui.detail("       pipx install cpitd");
                    ui.detail("  2. Create a virtualenv and install there:");
                    ui.detail("       python3 -m venv .venv && . .venv/bin/activate");
                    ui.detail("       pip install cpitd");
                    ui.detail("  3. Re-run init without cpitd: crosslink init --skip-cpitd");
                } else {
                    ui.detail(
                        "To install cpitd: pipx install cpitd  (or in a venv: pip install cpitd)",
                    );
                    ui.detail("Or re-run init without it: crosslink init --skip-cpitd");
                }
                ui.detail(
                    "cpitd is OPTIONAL: only `crosslink cpitd scan` and the sentinel cpitd source use it; everything else works without it.",
                );
            }
        }
    }

    // Driver SSH key detection and setup
    if !skip_signing {
        setup_driver_signing(path, signing_key, &ui)?;
    }

    // Auto-initialise agent identity so hub-cache commits are always
    // signing-aware. Creates agent ID + SSH key only — hub publishing and
    // signing configuration happen lazily on first sync. Errors are
    // non-fatal; the agent can still work unsigned.
    if crate::identity::AgentConfig::load(&crosslink_dir)?.is_none() {
        let agent_id = crate::utils::generate_compact_id();
        ui.step_start("Initializing agent identity");
        match init_agent_identity(&crosslink_dir, &agent_id) {
            Ok(()) => ui.step_ok(Some(&agent_id)),
            Err(e) => {
                println!();
                ui.warn(&format!("Could not auto-initialize agent: {e}"));
                ui.detail("Run `crosslink agent init <id>` manually to enable signing.");
            }
        }
    }

    // Shell alias setup (REQ-10)
    if let Some(ref choices) = tui_choices {
        setup_shell_alias(&ui, choices);
    }

    ui.success();

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use merge::{GITIGNORE_SECTION_END, GITIGNORE_SECTION_START};
    use tempfile::tempdir;

    /// Create a temp directory with a git repo and initial commit.
    /// Required because init now checks for .git and HEAD (#401).
    fn test_dir() -> tempfile::TempDir {
        let dir = tempdir().unwrap();
        let init = std::process::Command::new("git")
            .current_dir(dir.path())
            .args(["init"])
            .output()
            .expect("git init failed");
        assert!(init.status.success(), "git init failed");
        // Use -c flags so identity works even when env vars or global config are absent
        let commit = std::process::Command::new("git")
            .current_dir(dir.path())
            .args([
                "-c",
                "user.name=test",
                "-c",
                "user.email=test@test",
                "commit",
                "--allow-empty",
                "-m",
                "init",
            ])
            .output()
            .expect("git commit failed");
        assert!(commit.status.success(), "git commit --allow-empty failed");
        dir
    }

    /// Build default test opts (skips cpitd/signing, uses --defaults to skip TUI).
    fn test_opts(force: bool) -> InitOpts<'static> {
        InitOpts {
            force,
            update: false,
            dry_run: false,
            no_prompt: false,
            python_prefix: None,
            skip_cpitd: true,
            skip_signing: true,
            signing_key: None,
            reconfigure: false,
            defaults: true,
        }
    }

    #[test]
    fn test_run_fresh_init() {
        let dir = test_dir();
        let result = run(dir.path(), &test_opts(false));
        assert!(result.is_ok());

        // Verify directories created
        assert!(dir.path().join(".crosslink").exists());
        assert!(dir.path().join(".crosslink/rules").exists());
        assert!(dir.path().join(".crosslink/issues.db").exists());
        assert!(dir.path().join(".claude").exists());
        assert!(dir.path().join(".claude/hooks").exists());
        assert!(dir.path().join(".claude/mcp").exists());
        assert!(dir.path().join(".crosslink/hook-config.json").exists());
    }

    #[test]
    fn test_run_creates_hook_files() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Verify hook files
        assert!(dir.path().join(".claude/settings.json").exists());
        assert!(dir.path().join(".claude/hooks/prompt-guard.py").exists());
        assert!(dir.path().join(".claude/hooks/post-edit-check.py").exists());
        assert!(dir.path().join(".claude/hooks/session-start.py").exists());
        assert!(dir.path().join(".claude/hooks/pre-web-check.py").exists());
        assert!(dir.path().join(".claude/hooks/work-check.py").exists());
        assert!(dir
            .path()
            .join(".claude/hooks/crosslink_config.py")
            .exists());
        assert!(dir.path().join(".claude/mcp/safe-fetch-server.py").exists());
        assert!(dir.path().join(".claude/mcp/knowledge-server.py").exists());
        assert!(dir
            .path()
            .join(".claude/mcp/agent-prompt-server.py")
            .exists());
        assert!(dir.path().join(".mcp.json").exists());
    }

    #[test]
    fn test_run_creates_rule_files() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        let rules_dir = dir.path().join(".crosslink/rules");
        assert!(rules_dir.join("global.md").exists());
        assert!(rules_dir.join("project.md").exists());
        assert!(rules_dir.join("rust.md").exists());
        assert!(rules_dir.join("python.md").exists());
        assert!(rules_dir.join("javascript.md").exists());
        assert!(rules_dir.join("typescript.md").exists());
        assert!(rules_dir.join("tracking-strict.md").exists());
        assert!(rules_dir.join("tracking-normal.md").exists());
        assert!(rules_dir.join("tracking-relaxed.md").exists());
    }

    #[test]
    fn test_run_already_initialized_no_force() {
        let dir = test_dir();

        // First init
        run(dir.path(), &test_opts(false)).unwrap();

        // Second init without force - should succeed but not recreate
        let result = run(dir.path(), &test_opts(false));
        assert!(result.is_ok());
    }

    #[test]
    fn test_run_force_update() {
        let dir = test_dir();

        // First init
        run(dir.path(), &test_opts(false)).unwrap();

        // Modify a hook file
        let hook_path = dir.path().join(".claude/hooks/prompt-guard.py");
        fs::write(&hook_path, "# modified").unwrap();

        // Force update
        run(dir.path(), &test_opts(true)).unwrap();

        // Verify file was restored
        let content = fs::read_to_string(&hook_path).unwrap();
        assert_ne!(content, "# modified");
        assert!(content.contains("python") || content.contains("def") || content.len() > 20);
    }

    /// Keys that the embedded `MCP_JSON` is expected to manage.
    fn embedded_mcp_keys() -> Vec<String> {
        let embedded: serde_json::Value = serde_json::from_str(MCP_JSON).unwrap();
        embedded["mcpServers"]
            .as_object()
            .unwrap()
            .keys()
            .cloned()
            .collect()
    }

    #[test]
    fn test_force_init_preserves_existing_mcp_servers() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Add a custom MCP server entry alongside the embedded ones
        let mcp_path = dir.path().join(".mcp.json");
        let mut content: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&mcp_path).unwrap()).unwrap();
        content["mcpServers"]["my-custom-server"] = serde_json::json!({
            "command": "node",
            "args": ["my-server.js"]
        });
        fs::write(&mcp_path, serde_json::to_string_pretty(&content).unwrap()).unwrap();

        // Force update
        run(dir.path(), &test_opts(true)).unwrap();

        // Verify all embedded keys and the custom key are present
        let result: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&mcp_path).unwrap()).unwrap();
        let servers = result["mcpServers"].as_object().unwrap();

        for key in embedded_mcp_keys() {
            assert!(
                servers.contains_key(&key),
                "embedded key \"{key}\" should exist"
            );
        }
        assert!(
            servers.contains_key("my-custom-server"),
            "custom server should be preserved"
        );
        assert_eq!(
            servers["my-custom-server"]["command"].as_str().unwrap(),
            "node"
        );
    }

    #[test]
    fn test_force_init_returns_warnings_for_overwritten_keys() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // The first init created .mcp.json with the embedded keys.
        // A second force init should warn about overwriting each one.
        let mcp_path = dir.path().join(".mcp.json");
        let warnings = write_mcp_json_merged(&mcp_path).unwrap();

        let expected_keys = embedded_mcp_keys();
        assert_eq!(
            warnings.len(),
            expected_keys.len(),
            "should warn once per embedded key"
        );
        for key in &expected_keys {
            assert!(
                warnings.iter().any(|w| w.contains(key)),
                "should warn about overwriting \"{key}\""
            );
        }
    }

    #[test]
    fn test_write_mcp_json_merged_creates_fresh_file() {
        let dir = test_dir();
        let mcp_path = dir.path().join(".mcp.json");

        // No pre-existing file
        assert!(!mcp_path.exists());

        let warnings = write_mcp_json_merged(&mcp_path).unwrap();
        assert!(
            warnings.is_empty(),
            "fresh creation should produce no warnings"
        );

        let content: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&mcp_path).unwrap()).unwrap();
        let servers = content["mcpServers"].as_object().unwrap();

        // Should contain exactly the embedded keys
        let expected_keys = embedded_mcp_keys();
        assert_eq!(servers.len(), expected_keys.len());
        for key in &expected_keys {
            assert!(
                servers.contains_key(key),
                "fresh file should contain \"{key}\""
            );
        }
    }

    #[test]
    fn test_force_init_fails_on_malformed_mcp_json() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Write invalid JSON to .mcp.json
        let mcp_path = dir.path().join(".mcp.json");
        fs::write(&mcp_path, "not json {{{").unwrap();

        // Force init should fail, not silently overwrite
        let result = run(dir.path(), &test_opts(true));
        assert!(result.is_err());
        let err = format!("{:#}", result.unwrap_err());
        assert!(
            err.contains("invalid JSON"),
            "Error should mention invalid JSON, got: {err}"
        );

        // Original (broken) content should be untouched
        let content = fs::read_to_string(&mcp_path).unwrap();
        assert_eq!(content, "not json {{{");
    }

    #[test]
    fn test_force_init_fails_on_non_object_mcp_json() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Write a JSON array to .mcp.json
        let mcp_path = dir.path().join(".mcp.json");
        fs::write(&mcp_path, "[1, 2, 3]").unwrap();

        // Force init should fail, not silently overwrite
        let result = run(dir.path(), &test_opts(true));
        assert!(result.is_err());
        let err = format!("{:#}", result.unwrap_err());
        assert!(
            err.contains("not a JSON object"),
            "Error should mention not a JSON object, got: {err}"
        );

        // Original content should be untouched
        let content = fs::read_to_string(&mcp_path).unwrap();
        assert_eq!(content, "[1, 2, 3]");
    }

    #[test]
    fn test_force_init_handles_empty_mcp_json_file() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Write empty file
        let mcp_path = dir.path().join(".mcp.json");
        fs::write(&mcp_path, "").unwrap();

        // Should fail — empty file is not valid JSON
        let result = run(dir.path(), &test_opts(true));
        assert!(result.is_err());
        let err = format!("{:#}", result.unwrap_err());
        assert!(
            err.contains("invalid JSON"),
            "Error should mention invalid JSON, got: {err}"
        );
    }

    #[test]
    fn test_force_init_fails_on_non_object_mcp_servers_value() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Write valid JSON where mcpServers is a string instead of object
        let mcp_path = dir.path().join(".mcp.json");
        fs::write(&mcp_path, r#"{"mcpServers": "banana"}"#).unwrap();

        // Should fail, not silently replace
        let result = run(dir.path(), &test_opts(true));
        assert!(result.is_err());
        let err = format!("{:#}", result.unwrap_err());
        assert!(
            err.contains("non-object mcpServers"),
            "Error should mention non-object mcpServers, got: {err}"
        );

        // Original content should be untouched
        let content = fs::read_to_string(&mcp_path).unwrap();
        assert_eq!(content, r#"{"mcpServers": "banana"}"#);
    }

    #[test]
    fn test_init_merges_into_mcp_json_without_mcp_servers_key() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Write a valid object with no mcpServers key
        let mcp_path = dir.path().join(".mcp.json");
        fs::write(&mcp_path, r#"{"someOtherKey": true}"#).unwrap();

        // Force init should add mcpServers, preserving the other key
        run(dir.path(), &test_opts(true)).unwrap();

        let content = fs::read_to_string(&mcp_path).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
        assert_eq!(parsed["someOtherKey"], true);
        assert!(parsed["mcpServers"]["crosslink-safe-fetch"].is_object());
        assert!(parsed["mcpServers"]["crosslink-knowledge"].is_object());
    }

    #[test]
    fn test_run_partial_init_crosslink_only() {
        let dir = test_dir();

        // Create only .crosslink directory
        fs::create_dir_all(dir.path().join(".crosslink")).unwrap();

        let result = run(dir.path(), &test_opts(false));
        assert!(result.is_ok());

        // .claude should now exist
        assert!(dir.path().join(".claude").exists());
    }

    #[test]
    fn test_run_partial_init_claude_only() {
        let dir = test_dir();

        // Create only .claude directory
        fs::create_dir_all(dir.path().join(".claude")).unwrap();

        let result = run(dir.path(), &test_opts(false));
        assert!(result.is_ok());

        // .crosslink should now exist
        assert!(dir.path().join(".crosslink").exists());
    }

    #[test]
    fn test_run_database_usable() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Open the created database and verify it works
        let db_path = dir.path().join(".crosslink/issues.db");
        let db = Database::open(&db_path).unwrap();

        // Should be able to create an issue
        let id = db.create_issue("Test issue", None, "medium").unwrap();
        assert!(id > 0);
    }

    #[test]
    fn test_run_rule_files_not_empty() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        let rules_dir = dir.path().join(".crosslink/rules");

        // Verify rule files have content
        let global = fs::read_to_string(rules_dir.join("global.md")).unwrap();
        assert!(!global.is_empty());

        let rust = fs::read_to_string(rules_dir.join("rust.md")).unwrap();
        assert!(!rust.is_empty());
    }

    #[test]
    fn test_run_force_updates_rules() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Modify a rule file
        let rule_path = dir.path().join(".crosslink/rules/global.md");
        fs::write(&rule_path, "# modified rule").unwrap();

        // Force update
        run(dir.path(), &test_opts(true)).unwrap();

        // Verify file was restored
        let content = fs::read_to_string(&rule_path).unwrap();
        assert_ne!(content, "# modified rule");
    }

    #[test]
    fn test_run_idempotent_with_force() {
        let dir = test_dir();

        // Multiple force runs should all succeed
        for _ in 0..3 {
            let result = run(dir.path(), &test_opts(true));
            assert!(result.is_ok());
        }

        // All files should still exist
        assert!(dir.path().join(".crosslink/issues.db").exists());
        assert!(dir.path().join(".claude/settings.json").exists());
    }

    #[test]
    #[allow(clippy::const_is_empty)]
    fn test_embedded_constants_not_empty() {
        // Verify all embedded constants have content
        assert!(!SETTINGS_JSON.is_empty());
        assert!(!PROMPT_GUARD_PY.is_empty());
        assert!(!POST_EDIT_CHECK_PY.is_empty());
        assert!(!SESSION_START_PY.is_empty());
        assert!(!PRE_WEB_CHECK_PY.is_empty());
        assert!(!WORK_CHECK_PY.is_empty());
        assert!(!CROSSLINK_CONFIG_PY.is_empty());
        assert!(!HEARTBEAT_PY.is_empty());
        assert!(!SAFE_FETCH_SERVER_PY.is_empty());
        assert!(!KNOWLEDGE_SERVER_PY.is_empty());
        assert!(!AGENT_PROMPT_SERVER_PY.is_empty());
        assert!(!MCP_JSON.is_empty());
        // All auto-discovered command files should be non-empty
        assert!(
            COMMAND_FILES.len() >= 11,
            "Expected at least 11 command files, found {}",
            COMMAND_FILES.len()
        );
        for (filename, content) in COMMAND_FILES {
            assert!(!content.is_empty(), "Command file {filename} is empty");
        }
        assert!(!RULE_SANITIZE_PATTERNS.is_empty());
        assert!(!HOOK_CONFIG_JSON.is_empty());
        assert!(!RULE_TRACKING_STRICT.is_empty());
        assert!(!RULE_TRACKING_NORMAL.is_empty());
        assert!(!RULE_TRACKING_RELAXED.is_empty());
        assert!(!RULE_GLOBAL.is_empty());
        assert!(!RULE_RUST.is_empty());
    }

    #[test]
    fn test_rule_files_count() {
        // Verify we have the expected number of rule files
        assert!(RULE_FILES.len() >= 20);

        // All should have content
        for (name, content) in RULE_FILES {
            assert!(!name.is_empty(), "Rule file name should not be empty");
            assert!(!content.is_empty(), "Rule file {name} should not be empty");
        }
    }

    #[test]
    fn test_gitignore_includes_local_config() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        let content = fs::read_to_string(dir.path().join(".crosslink/.gitignore")).unwrap();
        assert!(content.contains("agent.json"));
        assert!(content.contains(".hub-cache/"));
        assert!(content.contains("hook-config.local.json"));
    }

    // --- Python toolchain detection tests ---

    #[test]
    fn test_detect_python_prefix_default() {
        let dir = test_dir();
        assert_eq!(detect_python_prefix(dir.path()), "python3");
    }

    #[test]
    fn test_detect_python_prefix_uv_lock() {
        let dir = test_dir();
        fs::write(dir.path().join("uv.lock"), "").unwrap();
        assert_eq!(detect_python_prefix(dir.path()), "uv run python3");
    }

    #[test]
    fn test_detect_python_prefix_uv_pyproject() {
        let dir = test_dir();
        fs::write(
            dir.path().join("pyproject.toml"),
            "[project]\nname = \"foo\"\n\n[tool.uv]\ndev-dependencies = []\n",
        )
        .unwrap();
        assert_eq!(detect_python_prefix(dir.path()), "uv run python3");
    }

    #[test]
    fn test_detect_python_prefix_poetry_lock() {
        let dir = test_dir();
        fs::write(dir.path().join("poetry.lock"), "").unwrap();
        assert_eq!(detect_python_prefix(dir.path()), "poetry run python3");
    }

    #[test]
    fn test_detect_python_prefix_poetry_pyproject() {
        let dir = test_dir();
        fs::write(
            dir.path().join("pyproject.toml"),
            "[project]\nname = \"foo\"\n\n[tool.poetry]\nname = \"foo\"\n",
        )
        .unwrap();
        assert_eq!(detect_python_prefix(dir.path()), "poetry run python3");
    }

    #[test]
    fn test_detect_python_prefix_venv() {
        let dir = test_dir();
        fs::create_dir(dir.path().join(".venv")).unwrap();
        assert_eq!(detect_python_prefix(dir.path()), ".venv/bin/python3");
    }

    #[test]
    fn test_detect_python_prefix_pipenv() {
        let dir = test_dir();
        fs::write(dir.path().join("Pipfile"), "").unwrap();
        assert_eq!(detect_python_prefix(dir.path()), "pipenv run python3");
    }

    #[test]
    fn test_detect_python_prefix_pipenv_lock() {
        let dir = test_dir();
        fs::write(dir.path().join("Pipfile.lock"), "{}").unwrap();
        assert_eq!(detect_python_prefix(dir.path()), "pipenv run python3");
    }

    #[test]
    fn test_detect_python_prefix_uv_beats_poetry() {
        let dir = test_dir();
        // Both uv.lock and poetry.lock present — uv wins
        fs::write(dir.path().join("uv.lock"), "").unwrap();
        fs::write(dir.path().join("poetry.lock"), "").unwrap();
        assert_eq!(detect_python_prefix(dir.path()), "uv run python3");
    }

    #[test]
    fn test_detect_python_prefix_poetry_beats_venv() {
        let dir = test_dir();
        fs::write(dir.path().join("poetry.lock"), "").unwrap();
        fs::create_dir(dir.path().join(".venv")).unwrap();
        assert_eq!(detect_python_prefix(dir.path()), "poetry run python3");
    }

    #[test]
    fn test_detect_python_prefix_venv_beats_pipenv() {
        let dir = test_dir();
        fs::create_dir(dir.path().join(".venv")).unwrap();
        fs::write(dir.path().join("Pipfile"), "").unwrap();
        assert_eq!(detect_python_prefix(dir.path()), ".venv/bin/python3");
    }

    #[test]
    fn test_detect_python_prefix_pyproject_without_tools_is_default() {
        let dir = test_dir();
        // Plain pyproject.toml with no tool sections
        fs::write(
            dir.path().join("pyproject.toml"),
            "[project]\nname = \"foo\"\nversion = \"1.0\"\n",
        )
        .unwrap();
        assert_eq!(detect_python_prefix(dir.path()), "python3");
    }

    // --- Settings.json templating tests ---

    #[test]
    fn test_settings_json_default_uses_python3() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        let content = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
        assert!(
            content.contains("python3"),
            "Default init should use python3 in settings.json"
        );
        assert!(
            !content.contains(PYTHON_PREFIX_PLACEHOLDER),
            "Placeholder should be replaced"
        );
    }

    #[test]
    fn test_settings_json_uv_project() {
        let dir = test_dir();
        fs::write(dir.path().join("uv.lock"), "").unwrap();
        run(dir.path(), &test_opts(false)).unwrap();

        let content = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
        assert!(
            content.contains("uv run python3"),
            "uv project should use 'uv run python3' in settings.json"
        );
    }

    #[test]
    fn test_settings_json_cli_override() {
        let dir = test_dir();
        fs::write(dir.path().join("uv.lock"), "").unwrap();
        // CLI override should beat auto-detection
        run(
            dir.path(),
            &InitOpts {
                python_prefix: Some("custom-python"),
                ..test_opts(false)
            },
        )
        .unwrap();

        let content = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
        assert!(
            content.contains("custom-python"),
            "CLI override should be used in settings.json"
        );
        assert!(
            !content.contains("uv run python3"),
            "Auto-detected prefix should not appear when overridden"
        );
    }

    #[test]
    fn test_settings_json_produces_valid_json() {
        let dir = test_dir();
        fs::write(dir.path().join("uv.lock"), "").unwrap();
        run(dir.path(), &test_opts(false)).unwrap();

        let content = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
        let parsed: Result<serde_json::Value, _> = serde_json::from_str(&content);
        assert!(
            parsed.is_ok(),
            "Settings JSON should be valid after templating"
        );
    }

    #[test]
    fn test_force_re_detects_toolchain() {
        let dir = test_dir();
        // First init: no markers → python3
        run(dir.path(), &test_opts(false)).unwrap();
        let content = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
        assert!(content.contains("python3 \\\"$HOOK\\\""));

        // Add uv.lock, force re-init → should now use uv
        fs::write(dir.path().join("uv.lock"), "").unwrap();
        run(dir.path(), &test_opts(true)).unwrap();
        let content = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
        assert!(
            content.contains("uv run python3"),
            "Force re-init should re-detect toolchain"
        );
    }

    // --- Settings.json allowedTools merge tests ---

    /// The default allowedTools entries that the embedded template provides.
    fn embedded_allowed_tools() -> Vec<String> {
        let template: serde_json::Value = serde_json::from_str(SETTINGS_JSON).unwrap();
        template
            .get("allowedTools")
            .and_then(|v| v.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|v| v.as_str().map(String::from))
                    .collect()
            })
            .unwrap_or_default()
    }

    #[test]
    fn test_settings_json_includes_allowed_tools() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        let content = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
        let tools = parsed["allowedTools"]
            .as_array()
            .expect("allowedTools should be an array");

        for expected in embedded_allowed_tools() {
            assert!(
                tools.iter().any(|v| v.as_str() == Some(&expected)),
                "allowedTools should contain \"{expected}\""
            );
        }
    }

    #[test]
    fn test_settings_json_includes_tmux_and_worktree_permissions() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        let content = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
        let tools: Vec<&str> = parsed["allowedTools"]
            .as_array()
            .unwrap()
            .iter()
            .filter_map(|v| v.as_str())
            .collect();

        assert!(
            tools.contains(&"Bash(tmux *)"),
            "allowedTools should include tmux permission"
        );
        assert!(
            tools.contains(&"Bash(git worktree *)"),
            "allowedTools should include git worktree permission"
        );
    }

    #[test]
    fn test_force_init_preserves_user_allowed_tools() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Add a custom allowedTools entry
        let settings_path = dir.path().join(".claude/settings.json");
        let mut content: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
        content["allowedTools"]
            .as_array_mut()
            .unwrap()
            .push(serde_json::Value::String("Bash(my-custom-tool *)".into()));
        fs::write(
            &settings_path,
            serde_json::to_string_pretty(&content).unwrap(),
        )
        .unwrap();

        // Force re-init
        run(dir.path(), &test_opts(true)).unwrap();

        let result: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
        let tools: Vec<&str> = result["allowedTools"]
            .as_array()
            .unwrap()
            .iter()
            .filter_map(|v| v.as_str())
            .collect();

        // Should have embedded tools AND the custom one
        for expected in embedded_allowed_tools() {
            assert!(
                tools.contains(&expected.as_str()),
                "embedded tool \"{expected}\" should be preserved after force re-init"
            );
        }
        assert!(
            tools.contains(&"Bash(my-custom-tool *)"),
            "custom allowedTools entry should be preserved after force re-init"
        );
    }

    #[test]
    fn test_force_init_no_duplicate_allowed_tools() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Force re-init multiple times
        run(dir.path(), &test_opts(true)).unwrap();
        run(dir.path(), &test_opts(true)).unwrap();

        let settings_path = dir.path().join(".claude/settings.json");
        let content: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
        let tools: Vec<&str> = content["allowedTools"]
            .as_array()
            .unwrap()
            .iter()
            .filter_map(|v| v.as_str())
            .collect();

        // Each embedded tool should appear exactly once (no duplicates)
        for expected in embedded_allowed_tools() {
            let count = tools.iter().filter(|&&t| t == expected.as_str()).count();
            assert_eq!(
                count, 1,
                "\"{expected}\" should appear exactly once, found {count}"
            );
        }
    }

    #[test]
    fn test_settings_json_merge_fails_on_malformed_json() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Write invalid JSON to settings.json
        let settings_path = dir.path().join(".claude/settings.json");
        fs::write(&settings_path, "not json {{{").unwrap();

        // Force init should fail, not silently overwrite
        let result = run(dir.path(), &test_opts(true));
        assert!(result.is_err());
        let err = format!("{:#}", result.unwrap_err());
        assert!(
            err.contains("invalid JSON"),
            "Error should mention invalid JSON, got: {err}"
        );

        // Original (broken) content should be untouched
        let content = fs::read_to_string(&settings_path).unwrap();
        assert_eq!(content, "not json {{{");
    }

    #[test]
    fn test_settings_json_merge_fails_on_non_object() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Write a JSON array to settings.json
        let settings_path = dir.path().join(".claude/settings.json");
        fs::write(&settings_path, "[1, 2, 3]").unwrap();

        // Force init should fail, not silently overwrite
        let result = run(dir.path(), &test_opts(true));
        assert!(result.is_err());
        let err = format!("{:#}", result.unwrap_err());
        assert!(
            err.contains("not a JSON object"),
            "Error should mention not a JSON object, got: {err}"
        );
    }

    #[test]
    fn test_settings_json_merge_creates_fresh_file() {
        let dir = test_dir();
        let settings_path = dir.path().join(".claude/settings.json");
        fs::create_dir_all(dir.path().join(".claude")).unwrap();

        // No pre-existing file
        assert!(!settings_path.exists());

        write_settings_json_merged(&settings_path, "python3").unwrap();

        let content: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
        let tools: Vec<&str> = content["allowedTools"]
            .as_array()
            .unwrap()
            .iter()
            .filter_map(|v| v.as_str())
            .collect();

        for expected in embedded_allowed_tools() {
            assert!(
                tools.contains(&expected.as_str()),
                "fresh file should contain \"{expected}\""
            );
        }
    }

    // --- Root .gitignore tests ---

    #[test]
    fn test_init_creates_root_gitignore() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert!(content.contains(GITIGNORE_SECTION_START));
        assert!(content.contains(GITIGNORE_SECTION_END));
        assert!(content.contains(".crosslink/issues.db"));
        assert!(content.contains(".crosslink/agent.json"));
        assert!(content.contains(".crosslink/session.json"));
        assert!(content.contains(".crosslink/daemon.pid"));
        assert!(content.contains(".crosslink/keys/"));
        assert!(content.contains(".crosslink/.hub-cache/"));
        assert!(content.contains(".crosslink/hook-config.local.json"));
        assert!(content.contains(".claude/hooks/"));
        assert!(content.contains(".claude/commands/"));
        assert!(content.contains(".claude/mcp/"));
    }

    #[test]
    fn test_root_gitignore_idempotent() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        let first = fs::read_to_string(dir.path().join(".gitignore")).unwrap();

        // Force re-init should produce identical content
        run(dir.path(), &test_opts(true)).unwrap();
        let second = fs::read_to_string(dir.path().join(".gitignore")).unwrap();

        assert_eq!(
            first, second,
            "Re-init should not duplicate gitignore entries"
        );
    }

    #[test]
    fn test_root_gitignore_preserves_user_entries() {
        let dir = test_dir();

        // Write a pre-existing .gitignore with user content
        fs::write(dir.path().join(".gitignore"), "/target/\n*.log\n").unwrap();

        run(dir.path(), &test_opts(false)).unwrap();

        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert!(
            content.contains("/target/"),
            "User entries before managed section should be preserved"
        );
        assert!(
            content.contains("*.log"),
            "User entries before managed section should be preserved"
        );
        assert!(content.contains(GITIGNORE_SECTION_START));
        assert!(content.contains(".crosslink/issues.db"));
    }

    #[test]
    fn test_root_gitignore_preserves_entries_around_managed_section() {
        let dir = test_dir();

        // First init
        run(dir.path(), &test_opts(false)).unwrap();

        // Add user content before and after the managed section
        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        let new_content =
            format!("# My custom rules\n/build/\n\n{content}\n# Trailing rules\n*.tmp\n");
        fs::write(dir.path().join(".gitignore"), new_content).unwrap();

        // Force re-init
        run(dir.path(), &test_opts(true)).unwrap();

        let result = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert!(
            result.contains("/build/"),
            "Pre-section user entries preserved"
        );
        assert!(
            result.contains("*.tmp"),
            "Post-section user entries preserved"
        );
        assert!(
            result.contains(".crosslink/issues.db"),
            "Managed entries present"
        );

        // Should have exactly one managed section
        assert_eq!(
            result.matches(GITIGNORE_SECTION_START).count(),
            1,
            "Should have exactly one managed section start marker"
        );
        assert_eq!(
            result.matches(GITIGNORE_SECTION_END).count(),
            1,
            "Should have exactly one managed section end marker"
        );
    }

    #[test]
    fn test_root_gitignore_has_do_track_comments() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert!(
            content.contains("DO track"),
            "Should include DO track comments for documentation"
        );
        assert!(
            content.contains("hook-config.json"),
            "Should mention hook-config.json as tracked"
        );
    }

    #[test]
    fn test_write_root_gitignore_fresh() {
        let dir = test_dir();
        write_root_gitignore(dir.path()).unwrap();

        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert!(content.starts_with(GITIGNORE_SECTION_START));
        assert!(content.contains(GITIGNORE_SECTION_END));
        assert!(content.contains(".crosslink/issues.db"));
    }

    #[test]
    fn test_write_root_gitignore_replaces_section() {
        let dir = test_dir();

        // Write twice
        write_root_gitignore(dir.path()).unwrap();
        write_root_gitignore(dir.path()).unwrap();

        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert_eq!(
            content.matches(GITIGNORE_SECTION_START).count(),
            1,
            "Should have exactly one start marker after double write"
        );
    }

    #[test]
    fn test_crosslink_inner_gitignore_includes_integrations() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        let content = fs::read_to_string(dir.path().join(".crosslink/.gitignore")).unwrap();
        assert!(content.contains("integrations/"));
    }

    // --- Tier 1/2 smoke tests (GH issue #242) ---

    #[test]
    fn test_init_deploys_skill_files() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        let commands_dir = dir.path().join(".claude/commands");
        assert!(
            commands_dir.join("maintain.md").exists(),
            "maintain.md skill not deployed"
        );
        assert!(
            commands_dir.join("design.md").exists(),
            "design.md skill not deployed"
        );

        // Verify non-empty content
        let maintain = fs::read_to_string(commands_dir.join("maintain.md")).unwrap();
        assert!(!maintain.is_empty(), "maintain.md is empty");
        let design = fs::read_to_string(commands_dir.join("design.md")).unwrap();
        assert!(!design.is_empty(), "design.md is empty");
    }

    #[test]
    fn test_init_deploys_mcp_servers() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // All three server scripts must be deployed. Previously only
        // safe-fetch and knowledge were checked, which masked #554 — init
        // registered crosslink-agent-prompt in .mcp.json but never wrote
        // the script to disk, so every Claude Code session logged a
        // server-launch failure.
        let mcp_dir = dir.path().join(".claude/mcp");
        assert!(
            mcp_dir.join("safe-fetch-server.py").exists(),
            "safe-fetch-server.py not deployed"
        );
        assert!(
            mcp_dir.join("knowledge-server.py").exists(),
            "knowledge-server.py not deployed"
        );
        assert!(
            mcp_dir.join("agent-prompt-server.py").exists(),
            "agent-prompt-server.py not deployed"
        );

        // .mcp.json must reference all three MCP servers. If a server is
        // registered here without the matching script being deployed,
        // Claude Code will fail to launch it on startup (#554).
        let mcp_content = fs::read_to_string(dir.path().join(".mcp.json")).unwrap();
        let mcp: serde_json::Value = serde_json::from_str(&mcp_content).unwrap();
        let servers = mcp["mcpServers"].as_object().unwrap();
        assert!(
            servers.contains_key("crosslink-safe-fetch"),
            ".mcp.json missing crosslink-safe-fetch"
        );
        assert!(
            servers.contains_key("crosslink-knowledge"),
            ".mcp.json missing crosslink-knowledge"
        );
        assert!(
            servers.contains_key("crosslink-agent-prompt"),
            ".mcp.json missing crosslink-agent-prompt"
        );
    }

    #[test]
    fn test_force_init_deploys_skill_files() {
        let dir = test_dir();
        // First init
        run(dir.path(), &test_opts(false)).unwrap();

        // Delete skill files to simulate upgrade scenario
        let commands_dir = dir.path().join(".claude/commands");
        fs::remove_file(commands_dir.join("maintain.md")).unwrap();
        fs::remove_file(commands_dir.join("design.md")).unwrap();
        assert!(!commands_dir.join("maintain.md").exists());
        assert!(!commands_dir.join("design.md").exists());

        // Force init should re-deploy them
        run(dir.path(), &test_opts(true)).unwrap();
        assert!(commands_dir.join("maintain.md").exists());
        assert!(commands_dir.join("design.md").exists());
    }

    // --- Init manifest and --update tests ---

    fn update_opts() -> InitOpts<'static> {
        InitOpts {
            force: false,
            update: true,
            dry_run: false,
            no_prompt: true, // Tests are non-interactive
            python_prefix: None,
            skip_cpitd: true,
            skip_signing: true,
            signing_key: None,
            reconfigure: false,
            defaults: true,
        }
    }

    fn dry_run_opts() -> InitOpts<'static> {
        InitOpts {
            dry_run: true,
            ..update_opts()
        }
    }

    #[test]
    fn test_init_writes_manifest() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        let manifest_path = dir.path().join(".crosslink/init-manifest.json");
        assert!(manifest_path.exists(), "Manifest should be created on init");

        let content = fs::read_to_string(&manifest_path).unwrap();
        let manifest: serde_json::Value = serde_json::from_str(&content).unwrap();

        assert!(manifest.get("crosslink_version").is_some());
        assert!(manifest.get("initialized_at").is_some());
        assert!(manifest.get("files").is_some());

        let files = manifest["files"].as_object().unwrap();
        assert!(
            files.contains_key(".claude/hooks/prompt-guard.py"),
            "Manifest should track hook files"
        );
        assert!(
            files.contains_key(".claude/settings.json"),
            "Manifest should track settings.json"
        );
        assert!(
            files.contains_key(".claude/mcp/safe-fetch-server.py"),
            "Manifest should track MCP servers"
        );
        assert!(
            files.contains_key(".claude/mcp/knowledge-server.py"),
            "Manifest should track knowledge MCP server"
        );
        assert!(
            files.contains_key(".claude/mcp/agent-prompt-server.py"),
            "Manifest should track agent-prompt MCP server"
        );
    }

    #[test]
    fn test_force_init_updates_manifest() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        let manifest_path = dir.path().join(".crosslink/init-manifest.json");
        let first = fs::read_to_string(&manifest_path).unwrap();

        // Force re-init
        run(dir.path(), &test_opts(true)).unwrap();

        let second = fs::read_to_string(&manifest_path).unwrap();
        // Hashes should be the same (same templates), but timestamp may differ
        let m1: serde_json::Value = serde_json::from_str(&first).unwrap();
        let m2: serde_json::Value = serde_json::from_str(&second).unwrap();
        assert_eq!(
            m1["files"], m2["files"],
            "File hashes should be identical across force re-inits"
        );
    }

    #[test]
    fn test_update_no_changes_needed() {
        let dir = test_dir();
        // Fresh init creates manifest
        run(dir.path(), &test_opts(false)).unwrap();

        // Immediately update — nothing should change
        let result = run(dir.path(), &update_opts());
        assert!(result.is_ok());

        // Verify hook file wasn't modified (content same as template)
        let content = fs::read_to_string(dir.path().join(".claude/hooks/prompt-guard.py")).unwrap();
        assert!(content.contains("python") || content.contains("def") || content.len() > 20);
    }

    #[test]
    fn test_update_fails_without_init() {
        let dir = test_dir();
        // No init — update should fail
        let result = run(dir.path(), &update_opts());
        assert!(result.is_err());
        let err = format!("{:#}", result.unwrap_err());
        assert!(
            err.contains("not initialized"),
            "Should mention not initialized, got: {err}"
        );
    }

    #[test]
    fn test_update_preserves_user_modified_hook() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // User modifies a hook file
        let hook_path = dir.path().join(".claude/hooks/prompt-guard.py");
        fs::write(&hook_path, "# user customization\nprint('hello')").unwrap();

        // Update with --no-prompt — user-modified file should be kept
        run(dir.path(), &update_opts()).unwrap();

        let content = fs::read_to_string(&hook_path).unwrap();
        assert_eq!(
            content, "# user customization\nprint('hello')",
            "User-modified hook should not be overwritten"
        );
    }

    #[test]
    fn test_update_skips_deleted_files() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // User deletes a hook file
        let hook_path = dir.path().join(".claude/hooks/heartbeat.py");
        fs::remove_file(&hook_path).unwrap();

        // Update should NOT recreate deleted files
        run(dir.path(), &update_opts()).unwrap();

        assert!(
            !hook_path.exists(),
            "Deleted file should not be recreated by --update"
        );
    }

    #[test]
    fn test_update_dry_run_makes_no_changes() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Modify a hook file
        let hook_path = dir.path().join(".claude/hooks/prompt-guard.py");
        let original = fs::read_to_string(&hook_path).unwrap();
        fs::write(&hook_path, "# modified").unwrap();

        // Read manifest before dry run
        let manifest_before =
            fs::read_to_string(dir.path().join(".crosslink/init-manifest.json")).unwrap();

        // Dry run
        run(dir.path(), &dry_run_opts()).unwrap();

        // File should still be modified
        let content = fs::read_to_string(&hook_path).unwrap();
        assert_eq!(content, "# modified", "Dry run should not modify files");

        // Manifest should be unchanged
        let manifest_after =
            fs::read_to_string(dir.path().join(".crosslink/init-manifest.json")).unwrap();
        assert_eq!(
            manifest_before, manifest_after,
            "Dry run should not update manifest"
        );

        // Restore for other tests
        fs::write(&hook_path, original).unwrap();
    }

    #[test]
    fn test_force_dry_run_makes_no_changes() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Modify a hook file so we can detect overwrites
        let hook_path = dir.path().join(".claude/hooks/prompt-guard.py");
        fs::write(&hook_path, "# user-modified").unwrap();

        // Snapshot manifest
        let manifest_before =
            fs::read_to_string(dir.path().join(".crosslink/init-manifest.json")).unwrap();

        // Force + dry-run
        let dry_force = InitOpts {
            force: true,
            dry_run: true,
            ..test_opts(true)
        };
        run(dir.path(), &dry_force).unwrap();

        // File should still have the user modification
        let content = fs::read_to_string(&hook_path).unwrap();
        assert_eq!(
            content, "# user-modified",
            "Force dry-run should not overwrite files"
        );

        // Manifest should be unchanged
        let manifest_after =
            fs::read_to_string(dir.path().join(".crosslink/init-manifest.json")).unwrap();
        assert_eq!(
            manifest_before, manifest_after,
            "Force dry-run should not update manifest"
        );
    }

    #[test]
    fn test_update_preserves_user_allowed_tools() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Add a custom allowedTools entry
        let settings_path = dir.path().join(".claude/settings.json");
        let mut content: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
        content["allowedTools"]
            .as_array_mut()
            .unwrap()
            .push(serde_json::Value::String("Bash(my-tool *)".into()));
        fs::write(
            &settings_path,
            serde_json::to_string_pretty(&content).unwrap(),
        )
        .unwrap();

        // Run update
        run(dir.path(), &update_opts()).unwrap();

        // Custom tool should still be present
        let result: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
        let has_custom_tool = result["allowedTools"]
            .as_array()
            .unwrap()
            .iter()
            .filter_map(|v| v.as_str())
            .any(|t| t == "Bash(my-tool *)");
        assert!(
            has_custom_tool,
            "Custom allowedTools entry should survive --update"
        );
    }

    #[test]
    fn test_update_without_manifest_warns() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        // Remove manifest to simulate old install
        fs::remove_file(dir.path().join(".crosslink/init-manifest.json")).unwrap();

        // Update should still work (treats everything as potentially modified)
        let result = run(dir.path(), &update_opts());
        assert!(result.is_ok());
    }

    #[test]
    fn test_gitignore_includes_manifest() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert!(
            content.contains("init-manifest.json"),
            "Gitignore should include init-manifest.json"
        );
    }

    #[test]
    fn test_manifest_tracks_all_managed_files() {
        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        let manifest_content =
            fs::read_to_string(dir.path().join(".crosslink/init-manifest.json")).unwrap();
        let manifest: serde_json::Value = serde_json::from_str(&manifest_content).unwrap();
        let files = manifest["files"].as_object().unwrap();

        // Should track all hook files
        let hook_files = [
            "prompt-guard.py",
            "post-edit-check.py",
            "session-start.py",
            "pre-web-check.py",
            "work-check.py",
            "crosslink_config.py",
            "heartbeat.py",
        ];
        for hook in &hook_files {
            let key = format!(".claude/hooks/{hook}");
            assert!(files.contains_key(&key), "Manifest should track {key}");
        }

        // Should track MCP servers
        assert!(files.contains_key(".claude/mcp/safe-fetch-server.py"));
        assert!(files.contains_key(".claude/mcp/knowledge-server.py"));
        assert!(files.contains_key(".claude/mcp/agent-prompt-server.py"));

        // Should track settings.json
        assert!(files.contains_key(".claude/settings.json"));

        // Should track rule files
        assert!(files.contains_key(".crosslink/rules/global.md"));
        assert!(files.contains_key(".crosslink/rules/rust.md"));
    }

    #[test]
    fn test_manifest_hashes_match_file_content() {
        use manifest::sha256_hex;

        let dir = test_dir();
        run(dir.path(), &test_opts(false)).unwrap();

        let manifest_content =
            fs::read_to_string(dir.path().join(".crosslink/init-manifest.json")).unwrap();
        let manifest: manifest::InitManifest = serde_json::from_str(&manifest_content).unwrap();

        // Hook files should have hash matching on-disk content
        let hook_path = dir.path().join(".claude/hooks/prompt-guard.py");
        let on_disk = fs::read_to_string(&hook_path).unwrap();
        let expected_hash = sha256_hex(&on_disk);

        assert_eq!(
            manifest.files[".claude/hooks/prompt-guard.py"].sha256, expected_hash,
            "Manifest hash should match on-disk file for non-merged files"
        );
    }

    // =========================================================================
    // GH#586: populate_tracker_remote + detect_git_remotes
    // =========================================================================

    /// Helper: write a minimal `hook-config.json` to a tempdir. Mirrors the
    /// embedded template's shape but only includes the keys our tests care
    /// about so we can assert before/after on the `tracker_remote` field.
    fn write_minimal_hook_config(dir: &Path, body: &str) -> std::path::PathBuf {
        let crosslink_dir = dir.join(".crosslink");
        fs::create_dir_all(&crosslink_dir).unwrap();
        let path = crosslink_dir.join("hook-config.json");
        fs::write(&path, body).unwrap();
        path
    }

    /// Helper: add a git remote. Tests construct several different remote
    /// configurations via this helper to exercise the selection rules.
    fn add_remote(repo: &Path, name: &str, url: &str) {
        let out = std::process::Command::new("git")
            .current_dir(repo)
            .args(["remote", "add", name, url])
            .output()
            .expect("git remote add failed");
        assert!(
            out.status.success(),
            "git remote add {name}: {}",
            String::from_utf8_lossy(&out.stderr)
        );
    }

    #[test]
    fn test_detect_git_remotes_empty_when_no_remotes() {
        let dir = test_dir();
        let remotes = detect_git_remotes(dir.path());
        assert!(
            remotes.is_empty(),
            "fresh repo with no remotes should yield empty list; got {remotes:?}"
        );
    }

    #[test]
    fn test_detect_git_remotes_single() {
        let dir = test_dir();
        add_remote(dir.path(), "origin", "https://github.com/me/repo.git");
        assert_eq!(detect_git_remotes(dir.path()), vec!["origin".to_string()]);
    }

    #[test]
    fn test_detect_git_remotes_sorted() {
        let dir = test_dir();
        add_remote(dir.path(), "upstream", "https://github.com/up/r.git");
        add_remote(dir.path(), "fork", "https://github.com/me/r.git");
        add_remote(dir.path(), "origin", "https://github.com/me/r.git");
        // Sorted output makes the alphabetically-first tiebreaker in
        // populate_tracker_remote deterministic.
        assert_eq!(
            detect_git_remotes(dir.path()),
            vec![
                "fork".to_string(),
                "origin".to_string(),
                "upstream".to_string(),
            ]
        );
    }

    #[test]
    fn test_populate_tracker_remote_writes_single_remote() {
        let dir = test_dir();
        add_remote(dir.path(), "upstream", "https://example.com/r.git");
        let cfg = write_minimal_hook_config(dir.path(), "{}");

        populate_tracker_remote(&cfg, dir.path()).unwrap();

        let after: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&cfg).unwrap()).unwrap();
        assert_eq!(
            after.get("tracker_remote").and_then(|v| v.as_str()),
            Some("upstream"),
            "single remote should be selected unconditionally"
        );
    }

    #[test]
    fn test_populate_tracker_remote_prefers_origin_when_multiple() {
        let dir = test_dir();
        add_remote(dir.path(), "fork", "https://github.com/me/r.git");
        add_remote(dir.path(), "origin", "https://github.com/me/r.git");
        add_remote(dir.path(), "upstream", "https://github.com/up/r.git");
        let cfg = write_minimal_hook_config(dir.path(), "{}");

        populate_tracker_remote(&cfg, dir.path()).unwrap();

        let after: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&cfg).unwrap()).unwrap();
        assert_eq!(
            after.get("tracker_remote").and_then(|v| v.as_str()),
            Some("origin")
        );
    }

    #[test]
    fn test_populate_tracker_remote_falls_back_alphabetically_without_origin() {
        let dir = test_dir();
        add_remote(dir.path(), "upstream", "https://github.com/up/r.git");
        add_remote(dir.path(), "fork", "https://github.com/me/r.git");
        let cfg = write_minimal_hook_config(dir.path(), "{}");

        populate_tracker_remote(&cfg, dir.path()).unwrap();

        let after: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&cfg).unwrap()).unwrap();
        assert_eq!(
            after.get("tracker_remote").and_then(|v| v.as_str()),
            Some("fork"),
            "without origin, the alphabetically-first remote wins"
        );
    }

    #[test]
    fn test_populate_tracker_remote_writes_origin_when_no_remotes() {
        // User's pick: zero remotes still writes `origin` so the value is
        // visible in the config and the user can change it without
        // re-running init.
        let dir = test_dir();
        let cfg = write_minimal_hook_config(dir.path(), "{}");

        populate_tracker_remote(&cfg, dir.path()).unwrap();

        let after: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&cfg).unwrap()).unwrap();
        assert_eq!(
            after.get("tracker_remote").and_then(|v| v.as_str()),
            Some("origin")
        );
    }

    #[test]
    fn test_populate_tracker_remote_upgrades_default_when_repo_has_non_origin_only() {
        // Embedded template default + single non-origin remote → upgrade.
        let dir = test_dir();
        add_remote(dir.path(), "upstream", "https://example.com/r.git");
        let cfg = write_minimal_hook_config(dir.path(), r#"{"tracker_remote": "origin"}"#);

        populate_tracker_remote(&cfg, dir.path()).unwrap();

        let after: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&cfg).unwrap()).unwrap();
        assert_eq!(
            after.get("tracker_remote").and_then(|v| v.as_str()),
            Some("upstream"),
            "default 'origin' should be upgraded when the repo has a different real remote"
        );
    }

    #[test]
    fn test_populate_tracker_remote_byte_equal_noop_when_default_matches_reality() {
        // The critical case for `workflow diff --check`: when the on-disk
        // value already matches what populate would have chosen (or
        // when no remote is configured and "origin" is the placeholder),
        // the function must leave the file byte-for-byte unchanged so
        // serde's BTreeMap re-serialization doesn't reorder keys.
        let dir = test_dir();
        // Case A: zero remotes — "origin" is the conventional placeholder.
        let cfg = write_minimal_hook_config(
            dir.path(),
            "{\n  \"a_key\": 1,\n  \"tracker_remote\": \"origin\",\n  \"z_key\": 2\n}",
        );
        let before = fs::read_to_string(&cfg).unwrap();
        populate_tracker_remote(&cfg, dir.path()).unwrap();
        let after = fs::read_to_string(&cfg).unwrap();
        assert_eq!(
            before, after,
            "file must be byte-identical when value matches detection"
        );

        // Case B: an `origin` remote actually exists.
        add_remote(dir.path(), "origin", "https://example.com/r.git");
        populate_tracker_remote(&cfg, dir.path()).unwrap();
        let after_b = fs::read_to_string(&cfg).unwrap();
        assert_eq!(
            before, after_b,
            "file must stay byte-identical when current 'origin' matches detected 'origin'"
        );
    }

    #[test]
    fn test_populate_tracker_remote_preserves_manual_value() {
        let dir = test_dir();
        add_remote(dir.path(), "origin", "https://github.com/me/r.git");
        let cfg = write_minimal_hook_config(dir.path(), r#"{"tracker_remote": "custom-name"}"#);

        populate_tracker_remote(&cfg, dir.path()).unwrap();

        let after: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&cfg).unwrap()).unwrap();
        assert_eq!(
            after.get("tracker_remote").and_then(|v| v.as_str()),
            Some("custom-name"),
            "manually-set tracker_remote must survive populate (idempotency)"
        );
    }

    #[test]
    fn test_populate_tracker_remote_noop_when_config_missing() {
        let dir = test_dir();
        let cfg = dir.path().join(".crosslink/hook-config.json");
        // No fs::write — file doesn't exist.
        populate_tracker_remote(&cfg, dir.path()).unwrap();
        assert!(!cfg.exists(), "should not create config when missing");
    }

    #[test]
    fn test_populate_tracker_remote_repairs_text_placeholder_with_detected() {
        // GH#739: older builds wrote the literal UI placeholder
        // "(text)" into hook-config.json. populate_tracker_remote must
        // treat that as repairable (not as a manual edit) and overwrite
        // it with the detected remote.
        let dir = test_dir();
        add_remote(dir.path(), "origin", "https://github.com/me/r.git");
        let cfg = write_minimal_hook_config(dir.path(), r#"{"tracker_remote": "(text)"}"#);

        populate_tracker_remote(&cfg, dir.path()).unwrap();

        let after: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&cfg).unwrap()).unwrap();
        assert_eq!(
            after.get("tracker_remote").and_then(|v| v.as_str()),
            Some("origin"),
            "corrupt '(text)' placeholder must be repaired to the detected remote"
        );
    }

    #[test]
    fn test_populate_tracker_remote_repairs_text_placeholder_without_remotes() {
        // GH#739: even when no git remotes are configured yet, the
        // corrupt "(text)" placeholder must be replaced with the
        // template default "origin" so subsequent operations don't
        // fail with RemoteMisconfigured('(text)').
        let dir = test_dir();
        let cfg = write_minimal_hook_config(dir.path(), r#"{"tracker_remote": "(text)"}"#);

        populate_tracker_remote(&cfg, dir.path()).unwrap();

        let after: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&cfg).unwrap()).unwrap();
        assert_eq!(
            after.get("tracker_remote").and_then(|v| v.as_str()),
            Some("origin"),
            "corrupt '(text)' must fall back to 'origin' when no remotes are detected"
        );
    }

    #[test]
    fn test_populate_tracker_remote_repairs_text_placeholder_to_non_origin() {
        // GH#739: when the repo's only remote is non-origin, the
        // repair path must use the detected name (same upgrade path
        // as the template default).
        let dir = test_dir();
        add_remote(dir.path(), "upstream", "https://example.com/r.git");
        let cfg = write_minimal_hook_config(dir.path(), r#"{"tracker_remote": "(text)"}"#);

        populate_tracker_remote(&cfg, dir.path()).unwrap();

        let after: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&cfg).unwrap()).unwrap();
        assert_eq!(
            after.get("tracker_remote").and_then(|v| v.as_str()),
            Some("upstream"),
            "corrupt '(text)' must upgrade to the only non-origin remote"
        );
    }
}
