Skip to main content

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    // `git` is resolved once from PATH; fixed arguments are passed directly without a shell.
239    // foxguard: ignore[rs/no-command-injection]
240    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    // `git` is resolved once from PATH; fixed arguments are passed directly without a shell.
256    // foxguard: ignore[rs/no-command-injection]
257    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    // `git` is resolved once from PATH; fixed arguments are passed directly without a shell.
268    // foxguard: ignore[rs/no-command-injection]
269    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    // `git` is resolved once from PATH; arguments are passed directly without a shell.
294    // foxguard: ignore[rs/no-command-injection]
295    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    // `git` is resolved once from PATH; fixed arguments are passed directly without a shell.
323    // foxguard: ignore[rs/no-command-injection]
324    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}