frame-cli 0.3.0

CLI for Frame — five intention-verbs over one application: frame new scaffolds it, frame run serves it, frame test proves it (real browser included), frame check verifies it statically, frame doctor walks the prerequisites
Documentation
//! Initialising a git repository for a freshly-scaffolded application
//! (2026-07-22 ruling): `frame new` runs `git init` plus one initial commit —
//! but ONLY when the target is not already inside an existing git work tree.
//!
//! The guard mirrors `git rev-parse --is-inside-work-tree` run from the target
//! directory: if the scaffold landed inside an existing repository (a monorepo,
//! or a directory the operator had already `git init`-ed), nesting a second
//! repository would be a surprise, so `frame new` leaves the tree untouched and
//! says so. Otherwise it initialises a repository and commits every tracked
//! scaffolded file, so the very first `frame run` session already has a clean
//! baseline to diff against.

use std::path::Path;
use std::process::Command;

use thiserror::Error;

/// What `frame new` did about version control for the new tree.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitInit {
    /// A fresh repository was initialised and an initial commit made.
    Initialized,
    /// The target is inside an existing git work tree; no nested repository
    /// was created.
    SkippedInsideExistingRepo,
    /// `git` is not installed; the tree is fine but unversioned.
    SkippedGitMissing,
}

/// A failure running one of the git commands that DID run (never a missing-git
/// or already-inside-a-repo condition — those are reported as [`GitInit`]).
#[derive(Debug, Error)]
pub enum GitError {
    /// A git subcommand exited unsuccessfully.
    #[error("`git {command}` failed for the new application at `{path}`: {detail}")]
    Command {
        /// The git subcommand (e.g. `init`, `add -A`, `commit`).
        command: &'static str,
        /// The scaffold directory the command ran in.
        path: String,
        /// Captured stderr (or a spawn error) explaining the failure.
        detail: String,
    },
}

/// Initialises a git repository in `target` and makes one initial commit,
/// unless `target` is already inside a git work tree (skipped and reported) or
/// git is not installed (skipped and reported).
///
/// # Errors
///
/// Returns [`GitError::Command`] if a git command that actually ran (init,
/// stage, or commit) failed.
pub fn init_repo(target: &Path) -> Result<GitInit, GitError> {
    match inside_work_tree(target) {
        InsideCheck::Missing => return Ok(GitInit::SkippedGitMissing),
        InsideCheck::Inside => return Ok(GitInit::SkippedInsideExistingRepo),
        InsideCheck::Outside => {}
    }

    run(target, "init", &["init", "-q"])?;
    run(target, "add -A", &["add", "-A"])?;
    run(target, "commit", &commit_args())?;
    Ok(GitInit::Initialized)
}

/// Outcome of the `git rev-parse --is-inside-work-tree` guard.
enum InsideCheck {
    /// git is not installed.
    Missing,
    /// The directory is inside an existing git work tree.
    Inside,
    /// The directory is not inside any git work tree.
    Outside,
}

fn inside_work_tree(target: &Path) -> InsideCheck {
    match Command::new("git")
        .args(["rev-parse", "--is-inside-work-tree"])
        .current_dir(target)
        .output()
    {
        Err(_) => InsideCheck::Missing,
        Ok(output) => {
            // `--is-inside-work-tree` prints `true` and exits 0 when inside a
            // work tree; outside any repository it exits non-zero with a
            // "not a git repository" message. Anything but a clean `true`
            // means "not inside", so `frame new` initialises.
            if output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "true" {
                InsideCheck::Inside
            } else {
                InsideCheck::Outside
            }
        }
    }
}

/// The `git commit` argument vector, supplying a neutral author identity ONLY
/// when neither a repository nor a global identity is configured — so an
/// operator's real `user.name`/`user.email` is used whenever it exists, and a
/// bare environment (CI, a fresh machine) still gets a valid initial commit
/// instead of git's "please tell me who you are" refusal.
fn commit_args() -> Vec<&'static str> {
    let mut args = Vec::new();
    if !identity_configured() {
        args.extend_from_slice(&["-c", "user.name=Frame", "-c", "user.email=frame@localhost"]);
    }
    args.extend_from_slice(&["commit", "-q", "-m", "Initial commit (frame new)"]);
    args
}

/// Whether git already has both a `user.name` and a `user.email` it would use.
fn identity_configured() -> bool {
    configured("user.name") && configured("user.email")
}

fn configured(key: &str) -> bool {
    Command::new("git")
        .args(["config", "--get", key])
        .output()
        .map(|output| output.status.success() && !output.stdout.is_empty())
        .unwrap_or(false)
}

fn run(target: &Path, label: &'static str, args: &[&str]) -> Result<(), GitError> {
    let output = Command::new("git")
        .args(args)
        .current_dir(target)
        .output()
        .map_err(|error| GitError::Command {
            command: label,
            path: target.display().to_string(),
            detail: error.to_string(),
        })?;
    if output.status.success() {
        Ok(())
    } else {
        Err(GitError::Command {
            command: label,
            path: target.display().to_string(),
            detail: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
        })
    }
}