1#![cfg(feature = "cli")]
2
3use std::env;
4use std::io;
5use std::path::Path;
6use std::process::{Command, Stdio};
7
8use serde::Serialize;
9
10use crate::scaffold::engine::ScaffoldOutcome;
11
12const DEFAULT_GIT_NAME: &str = "Greentic Scaffold";
13const DEFAULT_GIT_EMAIL: &str = "builders@greentic.ai";
14
15#[derive(Debug, Serialize)]
16pub struct PostInitReport {
17 pub git: GitInitReport,
18 pub next_steps: Vec<String>,
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
20 pub events: Vec<PostHookEvent>,
21}
22
23#[derive(Debug, Serialize)]
24pub struct PostHookEvent {
25 pub stage: String,
26 pub status: String,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub message: Option<String>,
29}
30
31impl PostHookEvent {
32 fn new(stage: &str, status: &str, message: Option<String>) -> Self {
33 Self {
34 stage: stage.into(),
35 status: status.into(),
36 message,
37 }
38 }
39}
40
41#[derive(Debug, Serialize)]
42pub struct GitInitReport {
43 pub status: GitInitStatus,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub commit: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub message: Option<String>,
48}
49
50impl GitInitReport {
51 fn initialized(commit: String) -> Self {
52 Self {
53 status: GitInitStatus::Initialized,
54 commit: Some(commit),
55 message: None,
56 }
57 }
58
59 fn already_present(reason: impl Into<String>) -> Self {
60 Self {
61 status: GitInitStatus::AlreadyPresent,
62 commit: None,
63 message: Some(reason.into()),
64 }
65 }
66
67 fn inside_worktree() -> Self {
68 Self {
69 status: GitInitStatus::InsideWorktree,
70 commit: None,
71 message: Some("target directory is already inside a git worktree".into()),
72 }
73 }
74
75 fn skipped(reason: impl Into<String>) -> Self {
76 Self {
77 status: GitInitStatus::Skipped,
78 commit: None,
79 message: Some(reason.into()),
80 }
81 }
82
83 fn failed(reason: impl Into<String>) -> Self {
84 Self {
85 status: GitInitStatus::Failed,
86 commit: None,
87 message: Some(reason.into()),
88 }
89 }
90}
91
92#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
93#[serde(rename_all = "kebab-case")]
94pub enum GitInitStatus {
95 Initialized,
96 AlreadyPresent,
97 InsideWorktree,
98 Skipped,
99 Failed,
100}
101
102pub fn run_post_init(outcome: &ScaffoldOutcome, skip_git: bool) -> PostInitReport {
103 let mut events = Vec::new();
104 let git = if skip_git {
105 events.push(PostHookEvent::new(
106 "git-init",
107 "skipped",
108 Some("git scaffolding disabled via flag/env".into()),
109 ));
110 GitInitReport::skipped("git scaffolding disabled via flag/env")
111 } else {
112 initialize_git_repo(&outcome.path, &outcome.template, &mut events)
113 };
114 let next_steps = default_next_steps(&outcome.path);
115 PostInitReport {
116 git,
117 next_steps,
118 events,
119 }
120}
121
122fn default_next_steps(path: &Path) -> Vec<String> {
123 let cd = format!("cd {}", path.display());
124 vec![
125 cd,
126 "component-doctor .".into(),
127 "component-inspect component.manifest.json --json".into(),
128 "git status".into(),
129 ]
130}
131
132fn initialize_git_repo(
133 path: &Path,
134 template: &str,
135 events: &mut Vec<PostHookEvent>,
136) -> GitInitReport {
137 if !path.exists() {
138 events.push(PostHookEvent::new(
139 "git-init",
140 "failed",
141 Some("target directory is missing".into()),
142 ));
143 return GitInitReport::failed("target directory is missing");
144 }
145 let git_dir = path.join(".git");
146 if git_dir.exists() {
147 events.push(PostHookEvent::new(
148 "git-detect",
149 "already-present",
150 Some("directory already contains .git".into()),
151 ));
152 return GitInitReport::already_present("directory already contains .git");
153 }
154 let git = env::var("GIT").unwrap_or_else(|_| "git".to_owned());
155 match detect_existing_worktree(&git, path) {
156 Ok(true) => {
157 events.push(PostHookEvent::new(
158 "git-detect",
159 "inside-worktree",
160 Some("target directory belongs to an existing git worktree".into()),
161 ));
162 return GitInitReport::inside_worktree();
163 }
164 Ok(false) => {}
165 Err(GitProbeError::MissingBinary) => {
166 events.push(PostHookEvent::new(
167 "git-detect",
168 "skipped",
169 Some("git binary not found in PATH".into()),
170 ));
171 return GitInitReport::skipped("git binary not found in PATH");
172 }
173 Err(GitProbeError::Io(err)) => {
174 let msg = format!("git rev-parse failed: {err}");
175 events.push(PostHookEvent::new(
176 "git-detect",
177 "failed",
178 Some(msg.clone()),
179 ));
180 return GitInitReport::failed(msg);
181 }
182 }
183
184 match git_init(&git, path) {
185 Ok(()) => {}
186 Err(GitInitError::MissingBinary) => {
187 events.push(PostHookEvent::new(
188 "git-init",
189 "skipped",
190 Some("git binary not found in PATH".into()),
191 ));
192 return GitInitReport::skipped("git binary not found in PATH");
193 }
194 Err(GitInitError::CommandFailed(cmd, output)) => {
195 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
196 let message = if stderr.is_empty() {
197 format!("{cmd} failed with exit code {}", output.status)
198 } else {
199 format!("{cmd} failed: {stderr}")
200 };
201 events.push(PostHookEvent::new(
202 "git-init",
203 "failed",
204 Some(message.clone()),
205 ));
206 return GitInitReport::failed(message);
207 }
208 Err(GitInitError::Io(err)) => {
209 let msg = format!("failed to run git command: {err}");
210 events.push(PostHookEvent::new("git-init", "failed", Some(msg.clone())));
211 return GitInitReport::failed(msg);
212 }
213 }
214 events.push(PostHookEvent::new("git-init", "ok", None));
215
216 if let Err(err) = git_add_all(&git, path) {
217 events.push(PostHookEvent::new("git-add", "failed", Some(err.clone())));
218 return GitInitReport::failed(err);
219 }
220 events.push(PostHookEvent::new("git-add", "ok", None));
221 match git_commit_initial(&git, path, template) {
222 Ok(commit) => {
223 events.push(PostHookEvent::new("git-commit", "ok", None));
224 GitInitReport::initialized(commit)
225 }
226 Err(err) => {
227 events.push(PostHookEvent::new(
228 "git-commit",
229 "failed",
230 Some(err.clone()),
231 ));
232 GitInitReport::failed(err)
233 }
234 }
235}
236
237fn detect_existing_worktree(git: &str, path: &Path) -> Result<bool, GitProbeError> {
238 let output = Command::new(git)
241 .arg("rev-parse")
242 .arg("--is-inside-work-tree")
243 .current_dir(path)
244 .stdout(Stdio::null())
245 .stderr(Stdio::null())
246 .output();
247 match output {
248 Ok(out) => Ok(out.status.success()),
249 Err(err) if err.kind() == io::ErrorKind::NotFound => Err(GitProbeError::MissingBinary),
250 Err(err) => Err(GitProbeError::Io(err)),
251 }
252}
253
254fn git_init(git: &str, path: &Path) -> Result<(), GitInitError> {
255 let output = Command::new(git).arg("init").current_dir(path).output();
258 match output {
259 Ok(out) if out.status.success() => Ok(()),
260 Ok(out) => Err(GitInitError::CommandFailed("git init".into(), out)),
261 Err(err) if err.kind() == io::ErrorKind::NotFound => Err(GitInitError::MissingBinary),
262 Err(err) => Err(GitInitError::Io(err)),
263 }
264}
265
266fn git_add_all(git: &str, path: &Path) -> Result<(), String> {
267 let output = Command::new(git)
270 .arg("add")
271 .arg("--all")
272 .current_dir(path)
273 .output();
274 match output {
275 Ok(out) if out.status.success() => Ok(()),
276 Ok(out) => {
277 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
278 let msg = if stderr.is_empty() {
279 format!("git add --all failed with exit code {}", out.status)
280 } else {
281 format!("git add --all failed: {stderr}")
282 };
283 Err(msg)
284 }
285 Err(err) if err.kind() == io::ErrorKind::NotFound => {
286 Err("git binary not found in PATH".into())
287 }
288 Err(err) => Err(format!("failed to run git add: {err}")),
289 }
290}
291
292fn git_commit_initial(git: &str, path: &Path, template: &str) -> Result<String, String> {
293 let mut cmd = Command::new(git);
296 cmd.arg("commit")
297 .arg("-m")
298 .arg(format!("chore(init): scaffold component from {template}"))
299 .current_dir(path);
300 ensure_git_identity(&mut cmd);
301 let output = cmd.output();
302 let output = match output {
303 Ok(out) => out,
304 Err(err) if err.kind() == io::ErrorKind::NotFound => {
305 return Err("git binary not found in PATH".into());
306 }
307 Err(err) => return Err(format!("failed to run git commit: {err}")),
308 };
309 if !output.status.success() {
310 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
311 let message = if stderr.is_empty() {
312 "git commit failed".to_string()
313 } else {
314 format!("git commit failed: {stderr}")
315 };
316 return Err(message);
317 }
318 read_head_hash(git, path)
319}
320
321fn read_head_hash(git: &str, path: &Path) -> Result<String, String> {
322 let output = Command::new(git)
325 .arg("rev-parse")
326 .arg("HEAD")
327 .current_dir(path)
328 .output();
329 let output = match output {
330 Ok(out) => out,
331 Err(err) if err.kind() == io::ErrorKind::NotFound => {
332 return Err("git binary not found in PATH".into());
333 }
334 Err(err) => return Err(format!("failed to read git HEAD: {err}")),
335 };
336 if !output.status.success() {
337 return Err("git rev-parse HEAD failed".into());
338 }
339 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
340 Ok(hash)
341}
342
343fn ensure_git_identity(cmd: &mut Command) {
344 if env::var_os("GIT_AUTHOR_NAME").is_none() {
345 cmd.env("GIT_AUTHOR_NAME", DEFAULT_GIT_NAME);
346 }
347 if env::var_os("GIT_AUTHOR_EMAIL").is_none() {
348 cmd.env("GIT_AUTHOR_EMAIL", DEFAULT_GIT_EMAIL);
349 }
350 if env::var_os("GIT_COMMITTER_NAME").is_none() {
351 cmd.env("GIT_COMMITTER_NAME", DEFAULT_GIT_NAME);
352 }
353 if env::var_os("GIT_COMMITTER_EMAIL").is_none() {
354 cmd.env("GIT_COMMITTER_EMAIL", DEFAULT_GIT_EMAIL);
355 }
356}
357
358enum GitProbeError {
359 MissingBinary,
360 Io(io::Error),
361}
362
363enum GitInitError {
364 MissingBinary,
365 Io(io::Error),
366 CommandFailed(String, std::process::Output),
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use assert_fs::TempDir;
373
374 #[test]
375 fn creates_git_repo_with_commit() {
376 let temp = TempDir::new().expect("tempdir");
377 let project = temp.path().join("demo");
378 std::fs::create_dir_all(&project).expect("mkdir");
379 std::fs::write(project.join("README.md"), "# Demo\n").expect("write");
380
381 let outcome = ScaffoldOutcome {
382 name: "demo".into(),
383 template: "rust-wasi-p2-min".into(),
384 template_description: Some("demo template".into()),
385 template_tags: vec!["test".into()],
386 path: project.clone(),
387 created: vec!["README.md".into()],
388 };
389
390 let report = run_post_init(&outcome, false);
391 assert_eq!(report.git.status, GitInitStatus::Initialized);
392 assert!(project.join(".git").exists());
393 assert!(report.git.commit.is_some());
394 assert!(report.next_steps.iter().any(|step| step.contains("cd ")));
395 assert!(
396 report
397 .events
398 .iter()
399 .any(|event| event.stage == "git-commit" && event.status == "ok")
400 );
401 }
402
403 #[test]
404 fn skips_when_inside_existing_repo() {
405 let temp = TempDir::new().expect("tempdir");
406 let project = temp.path().join("outer");
407 std::fs::create_dir_all(project.join(".git")).expect("fake git dir");
408
409 let outcome = ScaffoldOutcome {
410 name: "demo".into(),
411 template: "rust-wasi-p2-min".into(),
412 template_description: None,
413 template_tags: vec![],
414 path: project.clone(),
415 created: vec![],
416 };
417 let report = run_post_init(&outcome, false);
418 assert!(matches!(
419 report.git.status,
420 GitInitStatus::AlreadyPresent | GitInitStatus::InsideWorktree
421 ));
422 assert!(
423 report
424 .events
425 .iter()
426 .any(|event| event.stage == "git-detect")
427 );
428 }
429
430 #[test]
431 fn honors_skip_flag() {
432 let temp = TempDir::new().expect("tempdir");
433 let project = temp.path().join("demo-skip");
434 std::fs::create_dir_all(&project).expect("mkdir");
435
436 let outcome = ScaffoldOutcome {
437 name: "demo-skip".into(),
438 template: "rust-wasi-p2-min".into(),
439 template_description: None,
440 template_tags: vec![],
441 path: project.clone(),
442 created: vec![],
443 };
444 let report = run_post_init(&outcome, true);
445 assert_eq!(report.git.status, GitInitStatus::Skipped);
446 assert!(
447 report
448 .events
449 .iter()
450 .any(|event| event.stage == "git-init" && event.status == "skipped")
451 );
452 }
453}