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}