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)
239 .arg("rev-parse")
240 .arg("--is-inside-work-tree")
241 .current_dir(path)
242 .stdout(Stdio::null())
243 .stderr(Stdio::null())
244 .output();
245 match output {
246 Ok(out) => Ok(out.status.success()),
247 Err(err) if err.kind() == io::ErrorKind::NotFound => Err(GitProbeError::MissingBinary),
248 Err(err) => Err(GitProbeError::Io(err)),
249 }
250}
251
252fn git_init(git: &str, path: &Path) -> Result<(), GitInitError> {
253 let output = Command::new(git).arg("init").current_dir(path).output();
254 match output {
255 Ok(out) if out.status.success() => Ok(()),
256 Ok(out) => Err(GitInitError::CommandFailed("git init".into(), out)),
257 Err(err) if err.kind() == io::ErrorKind::NotFound => Err(GitInitError::MissingBinary),
258 Err(err) => Err(GitInitError::Io(err)),
259 }
260}
261
262fn git_add_all(git: &str, path: &Path) -> Result<(), String> {
263 let output = Command::new(git)
264 .arg("add")
265 .arg("--all")
266 .current_dir(path)
267 .output();
268 match output {
269 Ok(out) if out.status.success() => Ok(()),
270 Ok(out) => {
271 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
272 let msg = if stderr.is_empty() {
273 format!("git add --all failed with exit code {}", out.status)
274 } else {
275 format!("git add --all failed: {stderr}")
276 };
277 Err(msg)
278 }
279 Err(err) if err.kind() == io::ErrorKind::NotFound => {
280 Err("git binary not found in PATH".into())
281 }
282 Err(err) => Err(format!("failed to run git add: {err}")),
283 }
284}
285
286fn git_commit_initial(git: &str, path: &Path, template: &str) -> Result<String, String> {
287 let mut cmd = Command::new(git);
288 cmd.arg("commit")
289 .arg("-m")
290 .arg(format!("chore(init): scaffold component from {template}"))
291 .current_dir(path);
292 ensure_git_identity(&mut cmd);
293 let output = cmd.output();
294 let output = match output {
295 Ok(out) => out,
296 Err(err) if err.kind() == io::ErrorKind::NotFound => {
297 return Err("git binary not found in PATH".into());
298 }
299 Err(err) => return Err(format!("failed to run git commit: {err}")),
300 };
301 if !output.status.success() {
302 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
303 let message = if stderr.is_empty() {
304 "git commit failed".to_string()
305 } else {
306 format!("git commit failed: {stderr}")
307 };
308 return Err(message);
309 }
310 read_head_hash(git, path)
311}
312
313fn read_head_hash(git: &str, path: &Path) -> Result<String, String> {
314 let output = Command::new(git)
315 .arg("rev-parse")
316 .arg("HEAD")
317 .current_dir(path)
318 .output();
319 let output = match output {
320 Ok(out) => out,
321 Err(err) if err.kind() == io::ErrorKind::NotFound => {
322 return Err("git binary not found in PATH".into());
323 }
324 Err(err) => return Err(format!("failed to read git HEAD: {err}")),
325 };
326 if !output.status.success() {
327 return Err("git rev-parse HEAD failed".into());
328 }
329 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
330 Ok(hash)
331}
332
333fn ensure_git_identity(cmd: &mut Command) {
334 if env::var_os("GIT_AUTHOR_NAME").is_none() {
335 cmd.env("GIT_AUTHOR_NAME", DEFAULT_GIT_NAME);
336 }
337 if env::var_os("GIT_AUTHOR_EMAIL").is_none() {
338 cmd.env("GIT_AUTHOR_EMAIL", DEFAULT_GIT_EMAIL);
339 }
340 if env::var_os("GIT_COMMITTER_NAME").is_none() {
341 cmd.env("GIT_COMMITTER_NAME", DEFAULT_GIT_NAME);
342 }
343 if env::var_os("GIT_COMMITTER_EMAIL").is_none() {
344 cmd.env("GIT_COMMITTER_EMAIL", DEFAULT_GIT_EMAIL);
345 }
346}
347
348enum GitProbeError {
349 MissingBinary,
350 Io(io::Error),
351}
352
353enum GitInitError {
354 MissingBinary,
355 Io(io::Error),
356 CommandFailed(String, std::process::Output),
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use assert_fs::TempDir;
363
364 #[test]
365 fn creates_git_repo_with_commit() {
366 let temp = TempDir::new().expect("tempdir");
367 let project = temp.path().join("demo");
368 std::fs::create_dir_all(&project).expect("mkdir");
369 std::fs::write(project.join("README.md"), "# Demo\n").expect("write");
370
371 let outcome = ScaffoldOutcome {
372 name: "demo".into(),
373 template: "rust-wasi-p2-min".into(),
374 template_description: Some("demo template".into()),
375 template_tags: vec!["test".into()],
376 path: project.clone(),
377 created: vec!["README.md".into()],
378 };
379
380 let report = run_post_init(&outcome, false);
381 assert_eq!(report.git.status, GitInitStatus::Initialized);
382 assert!(project.join(".git").exists());
383 assert!(report.git.commit.is_some());
384 assert!(report.next_steps.iter().any(|step| step.contains("cd ")));
385 assert!(
386 report
387 .events
388 .iter()
389 .any(|event| event.stage == "git-commit" && event.status == "ok")
390 );
391 }
392
393 #[test]
394 fn skips_when_inside_existing_repo() {
395 let temp = TempDir::new().expect("tempdir");
396 let project = temp.path().join("outer");
397 std::fs::create_dir_all(project.join(".git")).expect("fake git dir");
398
399 let outcome = ScaffoldOutcome {
400 name: "demo".into(),
401 template: "rust-wasi-p2-min".into(),
402 template_description: None,
403 template_tags: vec![],
404 path: project.clone(),
405 created: vec![],
406 };
407 let report = run_post_init(&outcome, false);
408 assert!(matches!(
409 report.git.status,
410 GitInitStatus::AlreadyPresent | GitInitStatus::InsideWorktree
411 ));
412 assert!(
413 report
414 .events
415 .iter()
416 .any(|event| event.stage == "git-detect")
417 );
418 }
419
420 #[test]
421 fn honors_skip_flag() {
422 let temp = TempDir::new().expect("tempdir");
423 let project = temp.path().join("demo-skip");
424 std::fs::create_dir_all(&project).expect("mkdir");
425
426 let outcome = ScaffoldOutcome {
427 name: "demo-skip".into(),
428 template: "rust-wasi-p2-min".into(),
429 template_description: None,
430 template_tags: vec![],
431 path: project.clone(),
432 created: vec![],
433 };
434 let report = run_post_init(&outcome, true);
435 assert_eq!(report.git.status, GitInitStatus::Skipped);
436 assert!(
437 report
438 .events
439 .iter()
440 .any(|event| event.stage == "git-init" && event.status == "skipped")
441 );
442 }
443}