omnidiff 0.2.0

Fast, robust, syntax-aware code diffing using tree-sitter ASTs
Documentation
/*  This file is part of the OmniDiff code diffing tool.
 *
 *  Copyright (C) 2026 Marko Ivankovic
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU Affero General Public License as published
 *  by the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU Affero General Public License for more details.
 *
 *  You should have received a copy of the GNU Affero General Public License
 *  along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

//! `omnidiff git configure`: an interactive wizard for the `git config` commands README's "Git
//! integration" section otherwise asks the user to run by hand.

use std::io::{self, IsTerminal};
use std::process::Command;

use anyhow::{Context, Result};

use crate::configure_prompt::{ask_yes_no, read_line, resolve_omnidiff_path, shell_quote};

/// Whether to write `git config` values with `--global` or to the current repository only.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Scope {
    Global,
    Local,
}

impl Scope {
    /// Always explicit, even for `Local`: an unscoped `git config --get` reads the effective
    /// (global-inherited) value, so `existing_value_note` would report a global value as local.
    fn flag(self) -> &'static str {
        match self {
            Scope::Global => "--global",
            Scope::Local => "--local",
        }
    }

    fn label(self) -> &'static str {
        match self {
            Scope::Global => "globally, for every repository",
            Scope::Local => "for this repository only",
        }
    }
}

/// Entry point for `omnidiff git configure`. Without a terminal on stdin it prints the manual
/// commands and fails rather than block on a read nobody will answer.
pub fn run() -> Result<()> {
    if !io::stdin().is_terminal() {
        print_manual_instructions();
        anyhow::bail!("`omnidiff git configure` needs an interactive terminal");
    }

    let omnidiff_path = resolve_omnidiff_path();
    println!("Configuring git to use {omnidiff_path} as its diff tool.\n");

    let scope = ask_scope()?;
    if scope == Scope::Local {
        ensure_inside_git_repo()?;
    }

    let difftool_cmd = difftool_command(&omnidiff_path);
    let set_difftool = ask_yes_no(
        &format!(
            "{}{}Set omnidiff as the default `git difftool`? [Y/n] (`difftool.omnidiff.cmd` = \
             `{difftool_cmd}`) ",
            existing_value_note("difftool.omnidiff.cmd", scope),
            // `diff.tool` is overwritten too, so an existing choice (meld, vimdiff) is shown.
            existing_value_note("diff.tool", scope)
        ),
        true,
    )?;
    let skip_prompt = set_difftool
        && ask_yes_no(
            "Skip git's \"view diff ... [Y/n]?\" confirmation before each file? [Y/n] ",
            true,
        )?;
    let set_external = ask_yes_no(
        &format!(
            "{}Also use omnidiff for plain `git diff`/`git log -p` (via `diff.external`)? This \
             makes every git diff go through omnidiff, always in its non-interactive text mode. \
             [y/N] ",
            existing_value_note("diff.external", scope)
        ),
        false,
    )?;

    if !set_difftool && !set_external {
        println!("\nNothing selected - no changes made.");
        return Ok(());
    }

    println!();
    if set_difftool {
        set_config(scope, "difftool.omnidiff.cmd", &difftool_cmd)?;
        set_config(scope, "diff.tool", "omnidiff")?;
        if skip_prompt {
            set_config(scope, "difftool.prompt", "false")?;
        }
    }
    if set_external {
        // The resolved path, like the difftool command, so git runs this build rather than
        // whatever `omnidiff` is first on PATH; git runs the value through a shell.
        set_config(scope, "diff.external", &shell_quote(&omnidiff_path))?;
    }

    println!("\nDone - configured {}.", scope.label());
    Ok(())
}

/// The `difftool.omnidiff.cmd` value. git runs it through `sh`, so the binary's path is quoted
/// as a shell word (a path under "Program Files" or "Application Support" has a space in it) and
/// `$LOCAL`/`$REMOTE` stay as the variables git expands.
fn difftool_command(omnidiff_path: &str) -> String {
    format!("{} \"$LOCAL\" \"$REMOTE\"", shell_quote(omnidiff_path))
}

/// `key`'s current value under `scope` as a line to show before the prompt that would overwrite it,
/// or an empty string if unset.
fn existing_value_note(key: &str, scope: Scope) -> String {
    match get_config(scope, key) {
        Some(value) => format!("(currently: {key} = {value})\n"),
        None => String::new(),
    }
}

fn get_config(scope: Scope, key: &str) -> Option<String> {
    let output = Command::new("git")
        .arg("config")
        .arg(scope.flag())
        .arg("--get")
        .arg(key)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
    (!value.is_empty()).then_some(value)
}

fn set_config(scope: Scope, key: &str, value: &str) -> Result<()> {
    let status = Command::new("git")
        .arg("config")
        .arg(scope.flag())
        .arg(key)
        .arg(value)
        .status()
        .with_context(|| format!("failed to run `git config {} {key} {value}`", scope.flag()))?;
    if !status.success() {
        anyhow::bail!("`git config {} {key} {value}` failed", scope.flag());
    }
    println!("  git config {} {key} {value}", scope.flag());
    Ok(())
}

fn ensure_inside_git_repo() -> Result<()> {
    let status = Command::new("git")
        .args(["rev-parse", "--git-dir"])
        .status()
        .context("failed to run `git rev-parse --git-dir` - is git installed?")?;
    if !status.success() {
        anyhow::bail!(
            "not inside a git repository - run this from within one, or choose global scope"
        );
    }
    Ok(())
}

fn print_manual_instructions() {
    eprintln!(
        "Run these manually instead (see README's \"Git integration\" section):\n\n\
         git config difftool.omnidiff.cmd 'omnidiff \"$LOCAL\" \"$REMOTE\"'\n\
         git config diff.tool omnidiff\n\
         git config difftool.prompt false\n\n\
         Add --global to any of these to apply them to every repository instead of just this \
         one. For plain `git diff`/`git log -p` too (always non-interactive):\n\n\
         git config diff.external omnidiff"
    );
}

/// Parses `ask_scope`'s answer; `None` means reprompt.
fn parse_scope(input: &str) -> Option<Scope> {
    match input.trim().to_lowercase().as_str() {
        "" | "g" | "global" => Some(Scope::Global),
        "l" | "local" => Some(Scope::Local),
        _ => None,
    }
}

fn ask_scope() -> Result<Scope> {
    loop {
        let input = read_line(
            "Configure globally (every repository) or just this one? [g/l] (default: g) ",
        )?;
        match parse_scope(&input) {
            Some(scope) => return Ok(scope),
            None => println!("'{input}' - please answer 'g' or 'l'."),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn difftool_command_quotes_a_path_with_spaces_and_keeps_gits_variables_bare() {
        assert_eq!(
            difftool_command("/usr/local/bin/omnidiff"),
            "/usr/local/bin/omnidiff \"$LOCAL\" \"$REMOTE\""
        );
        assert_eq!(
            difftool_command("/Applications/My Tools/omnidiff"),
            "'/Applications/My Tools/omnidiff' \"$LOCAL\" \"$REMOTE\""
        );
    }

    #[test]
    fn scope_flag_is_always_explicit() {
        assert_eq!(Scope::Global.flag(), "--global");
        assert_eq!(Scope::Local.flag(), "--local");
    }

    #[test]
    fn parse_scope_accepts_g_l_and_their_full_spellings_case_insensitively() {
        assert_eq!(parse_scope("g"), Some(Scope::Global));
        assert_eq!(parse_scope("G"), Some(Scope::Global));
        assert_eq!(parse_scope("global"), Some(Scope::Global));
        assert_eq!(parse_scope("Global"), Some(Scope::Global));
        assert_eq!(parse_scope("l"), Some(Scope::Local));
        assert_eq!(parse_scope("local"), Some(Scope::Local));
    }

    #[test]
    fn parse_scope_defaults_to_global_on_an_empty_line() {
        assert_eq!(parse_scope(""), Some(Scope::Global));
        assert_eq!(parse_scope("   "), Some(Scope::Global));
    }

    #[test]
    fn parse_scope_rejects_anything_else() {
        assert_eq!(parse_scope("yes"), None);
        assert_eq!(parse_scope("globalx"), None);
    }
}