Skip to main content

frame_cli/scaffold/
git.rs

1//! Initialising a git repository for a freshly-scaffolded application
2//! (2026-07-22 ruling): `frame new` runs `git init` plus one initial commit —
3//! but ONLY when the target is not already inside an existing git work tree.
4//!
5//! The guard mirrors `git rev-parse --is-inside-work-tree` run from the target
6//! directory: if the scaffold landed inside an existing repository (a monorepo,
7//! or a directory the operator had already `git init`-ed), nesting a second
8//! repository would be a surprise, so `frame new` leaves the tree untouched and
9//! says so. Otherwise it initialises a repository and commits every tracked
10//! scaffolded file, so the very first `frame run` session already has a clean
11//! baseline to diff against.
12
13use std::path::Path;
14use std::process::Command;
15
16use thiserror::Error;
17
18/// What `frame new` did about version control for the new tree.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum GitInit {
21    /// A fresh repository was initialised and an initial commit made.
22    Initialized,
23    /// The target is inside an existing git work tree; no nested repository
24    /// was created.
25    SkippedInsideExistingRepo,
26    /// `git` is not installed; the tree is fine but unversioned.
27    SkippedGitMissing,
28}
29
30/// A failure running one of the git commands that DID run (never a missing-git
31/// or already-inside-a-repo condition — those are reported as [`GitInit`]).
32#[derive(Debug, Error)]
33pub enum GitError {
34    /// A git subcommand exited unsuccessfully.
35    #[error("`git {command}` failed for the new application at `{path}`: {detail}")]
36    Command {
37        /// The git subcommand (e.g. `init`, `add -A`, `commit`).
38        command: &'static str,
39        /// The scaffold directory the command ran in.
40        path: String,
41        /// Captured stderr (or a spawn error) explaining the failure.
42        detail: String,
43    },
44}
45
46/// Initialises a git repository in `target` and makes one initial commit,
47/// unless `target` is already inside a git work tree (skipped and reported) or
48/// git is not installed (skipped and reported).
49///
50/// # Errors
51///
52/// Returns [`GitError::Command`] if a git command that actually ran (init,
53/// stage, or commit) failed.
54pub fn init_repo(target: &Path) -> Result<GitInit, GitError> {
55    match inside_work_tree(target) {
56        InsideCheck::Missing => return Ok(GitInit::SkippedGitMissing),
57        InsideCheck::Inside => return Ok(GitInit::SkippedInsideExistingRepo),
58        InsideCheck::Outside => {}
59    }
60
61    run(target, "init", &["init", "-q"])?;
62    run(target, "add -A", &["add", "-A"])?;
63    run(target, "commit", &commit_args())?;
64    Ok(GitInit::Initialized)
65}
66
67/// Outcome of the `git rev-parse --is-inside-work-tree` guard.
68enum InsideCheck {
69    /// git is not installed.
70    Missing,
71    /// The directory is inside an existing git work tree.
72    Inside,
73    /// The directory is not inside any git work tree.
74    Outside,
75}
76
77fn inside_work_tree(target: &Path) -> InsideCheck {
78    match Command::new("git")
79        .args(["rev-parse", "--is-inside-work-tree"])
80        .current_dir(target)
81        .output()
82    {
83        Err(_) => InsideCheck::Missing,
84        Ok(output) => {
85            // `--is-inside-work-tree` prints `true` and exits 0 when inside a
86            // work tree; outside any repository it exits non-zero with a
87            // "not a git repository" message. Anything but a clean `true`
88            // means "not inside", so `frame new` initialises.
89            if output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "true" {
90                InsideCheck::Inside
91            } else {
92                InsideCheck::Outside
93            }
94        }
95    }
96}
97
98/// The `git commit` argument vector, supplying a neutral author identity ONLY
99/// when neither a repository nor a global identity is configured — so an
100/// operator's real `user.name`/`user.email` is used whenever it exists, and a
101/// bare environment (CI, a fresh machine) still gets a valid initial commit
102/// instead of git's "please tell me who you are" refusal.
103fn commit_args() -> Vec<&'static str> {
104    let mut args = Vec::new();
105    if !identity_configured() {
106        args.extend_from_slice(&["-c", "user.name=Frame", "-c", "user.email=frame@localhost"]);
107    }
108    args.extend_from_slice(&["commit", "-q", "-m", "Initial commit (frame new)"]);
109    args
110}
111
112/// Whether git already has both a `user.name` and a `user.email` it would use.
113fn identity_configured() -> bool {
114    configured("user.name") && configured("user.email")
115}
116
117fn configured(key: &str) -> bool {
118    Command::new("git")
119        .args(["config", "--get", key])
120        .output()
121        .map(|output| output.status.success() && !output.stdout.is_empty())
122        .unwrap_or(false)
123}
124
125fn run(target: &Path, label: &'static str, args: &[&str]) -> Result<(), GitError> {
126    let output = Command::new("git")
127        .args(args)
128        .current_dir(target)
129        .output()
130        .map_err(|error| GitError::Command {
131            command: label,
132            path: target.display().to_string(),
133            detail: error.to_string(),
134        })?;
135    if output.status.success() {
136        Ok(())
137    } else {
138        Err(GitError::Command {
139            command: label,
140            path: target.display().to_string(),
141            detail: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
142        })
143    }
144}