1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
//! Apply lifecycle helpers (hooks, notify, params, git).
use crate::core::types;
use std::path::Path;
/// Run a local shell hook command. Returns Ok if the command succeeds, Err if it fails.
pub(crate) fn run_hook(name: &str, command: &str, verbose: bool) -> Result<(), String> {
if verbose {
eprintln!("Running {name} hook: {command}");
}
let output = std::process::Command::new("sh")
.arg("-c")
.arg(command)
.output()
.map_err(|e| format!("{name} hook failed to start: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"{} hook failed (exit {}): {}",
name,
output.status.code().unwrap_or(-1),
stderr.trim()
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.is_empty() {
print!("{stdout}");
}
Ok(())
}
/// FJ-225: Run a notification hook with template variable expansion.
pub(crate) fn run_notify(template: &str, vars: &[(&str, &str)]) {
let mut cmd = template.to_string();
for (key, value) in vars {
cmd = cmd.replace(&format!("{{{{{key}}}}}"), value);
}
let output = std::process::Command::new("sh")
.arg("-c")
.arg(&cmd)
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
if !stdout.is_empty() {
print!("{stdout}");
}
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
eprintln!(
"Warning: notify hook exited {}: {}",
out.status.code().unwrap_or(-1),
stderr.trim()
);
}
}
Err(e) => {
eprintln!("Warning: notify hook failed to start: {e}");
}
}
}
/// Parse KEY=VALUE param overrides and merge into config.
pub(crate) fn apply_param_overrides(
config: &mut types::ForjarConfig,
overrides: &[String],
) -> Result<(), String> {
for kv in overrides {
let (key, value) = kv
.split_once('=')
.ok_or_else(|| format!("invalid param '{kv}': expected KEY=VALUE"))?;
config.params.insert(
key.to_string(),
serde_yaml_ng::Value::String(value.to_string()),
);
}
Ok(())
}
// ========================================================================
// FJ-210: Workspace helpers
// ========================================================================
/// FJ-211: Load param overrides from an external YAML file.
/// The file must be a flat YAML mapping (key: value). Values are merged into
/// config.params, overriding any existing keys with the same name.
pub(crate) fn load_env_params(config: &mut types::ForjarConfig, path: &Path) -> Result<(), String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read env file {}: {}", path.display(), e))?;
let mapping: indexmap::IndexMap<String, serde_yaml_ng::Value> =
serde_yaml_ng::from_str(&content)
.map_err(|e| format!("invalid YAML in env file {}: {}", path.display(), e))?;
for (key, value) in mapping {
config.params.insert(key, value);
}
Ok(())
}
/// Git commit state directory after successful apply.
pub(crate) fn git_commit_state(
state_dir: &Path,
config_name: &str,
converged: u32,
) -> Result<(), String> {
let msg = format!("forjar: {config_name} — {converged} resource(s) converged");
// Find the git repo root from state_dir's parent
let repo_root = state_dir.parent().unwrap_or(Path::new("."));
// Refs #406 (E04): NEVER stage `state/<machine>/runs/`. Those files hold the
// script forjar executed, and the executor resolves `{{secrets.*}}` into the
// resource before codegen. `forjar init` now gitignores them, but this runs
// in repositories created before that and in ones whose `.gitignore` was
// written by hand — and a secret committed here cannot be un-committed by a
// later redaction.
//
// THE TRAILING `*` IS LOAD-BEARING. `:(exclude)state/*/runs/` — a directory
// pathspec — is honoured only while the runs tree is entirely UNTRACKED, in
// which case git excludes the directory without descending. The moment one
// file under it is tracked git matches per PATH, `state/*/runs/` does not
// wildmatch `state/local/runs/r1/x.script`, and every file there is staged
// again. That is exactly the repository this line exists for: one that has
// been running `--auto-commit` since before the fix. Measured: with the
// directory form, a tracked-and-modified `runs/` file was staged; with
// `state/*/runs/*` it is not, on both the tracked and the untracked tree,
// and `state/` outside `runs/` still stages either way.
let status = crate::core::gitenv::git_in(repo_root)
.args(["add", "state", ":(exclude)state/*/runs/*"])
.status()
.map_err(|e| format!("git add failed: {e}"))?;
if !status.success() {
return Err("git add state/ failed".to_string());
}
let status = crate::core::gitenv::git_in(repo_root)
.args(["commit", "--no-verify", "-m", &msg])
.status()
.map_err(|e| format!("git commit failed: {e}"))?;
if !status.success() {
return Err("git commit failed".to_string());
}
println!("Auto-committed state: {msg}");
Ok(())
}