greentic_component/cmd/
post.rs

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}