everything-claude-code 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +739 -0
- package/README.zh-CN.md +523 -0
- package/crates/ecc-kernel/Cargo.lock +160 -0
- package/crates/ecc-kernel/Cargo.toml +15 -0
- package/crates/ecc-kernel/src/main.rs +710 -0
- package/docs/ecc.md +117 -0
- package/package.json +45 -0
- package/packs/blueprint.json +8 -0
- package/packs/forge.json +16 -0
- package/packs/instinct.json +16 -0
- package/packs/orchestra.json +15 -0
- package/packs/proof.json +8 -0
- package/packs/sentinel.json +8 -0
- package/prompts/ecc/patch.md +25 -0
- package/prompts/ecc/plan.md +28 -0
- package/schemas/ecc.apply.schema.json +35 -0
- package/schemas/ecc.config.schema.json +37 -0
- package/schemas/ecc.lock.schema.json +34 -0
- package/schemas/ecc.patch.schema.json +25 -0
- package/schemas/ecc.plan.schema.json +32 -0
- package/schemas/ecc.run.schema.json +67 -0
- package/schemas/ecc.verify.schema.json +27 -0
- package/schemas/hooks.schema.json +81 -0
- package/schemas/package-manager.schema.json +17 -0
- package/schemas/plugin.schema.json +13 -0
- package/scripts/ecc/catalog.js +82 -0
- package/scripts/ecc/config.js +43 -0
- package/scripts/ecc/diff.js +113 -0
- package/scripts/ecc/exec.js +121 -0
- package/scripts/ecc/fixtures/basic/patches/impl-core.diff +8 -0
- package/scripts/ecc/fixtures/basic/patches/tests.diff +8 -0
- package/scripts/ecc/fixtures/basic/plan.json +23 -0
- package/scripts/ecc/fixtures/unauthorized/patches/impl-core.diff +8 -0
- package/scripts/ecc/fixtures/unauthorized/plan.json +15 -0
- package/scripts/ecc/git.js +139 -0
- package/scripts/ecc/id.js +37 -0
- package/scripts/ecc/install-kernel.js +344 -0
- package/scripts/ecc/json-extract.js +301 -0
- package/scripts/ecc/json.js +26 -0
- package/scripts/ecc/kernel.js +144 -0
- package/scripts/ecc/lock.js +36 -0
- package/scripts/ecc/paths.js +28 -0
- package/scripts/ecc/plan.js +57 -0
- package/scripts/ecc/project.js +37 -0
- package/scripts/ecc/providers/codex.js +168 -0
- package/scripts/ecc/providers/index.js +23 -0
- package/scripts/ecc/providers/mock.js +49 -0
- package/scripts/ecc/report.js +127 -0
- package/scripts/ecc/run.js +105 -0
- package/scripts/ecc/validate.js +325 -0
- package/scripts/ecc/verify.js +125 -0
- package/scripts/ecc.js +532 -0
- package/scripts/lib/package-manager.js +390 -0
- package/scripts/lib/session-aliases.js +432 -0
- package/scripts/lib/session-manager.js +396 -0
- package/scripts/lib/utils.js +426 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
use std::collections::BTreeSet;
|
|
3
|
+
use std::fs::{self, File};
|
|
4
|
+
use std::io::{self, BufRead, BufReader, Read, Write};
|
|
5
|
+
use std::path::{Component, Path, PathBuf};
|
|
6
|
+
use std::process::{Command, ExitCode, Stdio};
|
|
7
|
+
use time::format_description::well_known::Rfc3339;
|
|
8
|
+
use time::OffsetDateTime;
|
|
9
|
+
|
|
10
|
+
fn now_iso() -> String {
|
|
11
|
+
OffsetDateTime::now_utc()
|
|
12
|
+
.format(&Rfc3339)
|
|
13
|
+
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
fn read_stdin_json<T: for<'de> Deserialize<'de>>() -> Result<T, String> {
|
|
17
|
+
let mut buf = String::new();
|
|
18
|
+
io::stdin()
|
|
19
|
+
.read_to_string(&mut buf)
|
|
20
|
+
.map_err(|e| format!("failed to read stdin: {e}"))?;
|
|
21
|
+
let trimmed = buf.trim();
|
|
22
|
+
if trimmed.is_empty() {
|
|
23
|
+
return Err("missing JSON input on stdin".to_string());
|
|
24
|
+
}
|
|
25
|
+
serde_json::from_str(trimmed).map_err(|e| format!("invalid JSON input: {e}"))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
fn write_stdout_json<T: Serialize>(value: &T) -> Result<(), String> {
|
|
29
|
+
let out = serde_json::to_string(value).map_err(|e| format!("failed to serialize JSON: {e}"))?;
|
|
30
|
+
print!("{out}");
|
|
31
|
+
Ok(())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#[derive(Debug)]
|
|
35
|
+
struct CmdOut {
|
|
36
|
+
ok: bool,
|
|
37
|
+
status: i32,
|
|
38
|
+
stdout: String,
|
|
39
|
+
stderr: String,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fn run_cmd(program: &str, args: &[String], cwd: Option<&Path>) -> Result<CmdOut, String> {
|
|
43
|
+
let mut cmd = Command::new(program);
|
|
44
|
+
cmd.args(args);
|
|
45
|
+
if let Some(dir) = cwd {
|
|
46
|
+
cmd.current_dir(dir);
|
|
47
|
+
}
|
|
48
|
+
let output = cmd.output().map_err(|e| format!("{program} failed: {e}"))?;
|
|
49
|
+
let status = output.status.code().unwrap_or(1);
|
|
50
|
+
Ok(CmdOut {
|
|
51
|
+
ok: output.status.success(),
|
|
52
|
+
status,
|
|
53
|
+
stdout: String::from_utf8_lossy(&output.stdout).trim_end().to_string(),
|
|
54
|
+
stderr: String::from_utf8_lossy(&output.stderr).trim_end().to_string(),
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fn run_git(args: &[String], cwd: Option<&Path>) -> Result<CmdOut, String> {
|
|
59
|
+
run_cmd("git", args, cwd)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
fn normalize_worktree_path(path: &Path) -> PathBuf {
|
|
63
|
+
// Lexical normalization (no FS access): removes `.` and collapses `..` where possible.
|
|
64
|
+
let mut out: Vec<Component<'_>> = Vec::new();
|
|
65
|
+
for c in path.components() {
|
|
66
|
+
match c {
|
|
67
|
+
Component::CurDir => {}
|
|
68
|
+
Component::ParentDir => {
|
|
69
|
+
if let Some(last) = out.last() {
|
|
70
|
+
if matches!(last, Component::ParentDir) {
|
|
71
|
+
out.push(c);
|
|
72
|
+
} else if !matches!(last, Component::RootDir | Component::Prefix(_)) {
|
|
73
|
+
out.pop();
|
|
74
|
+
} else {
|
|
75
|
+
out.push(c);
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
out.push(c);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
_ => out.push(c),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let mut pb = PathBuf::new();
|
|
86
|
+
for c in out {
|
|
87
|
+
pb.push(c.as_os_str());
|
|
88
|
+
}
|
|
89
|
+
pb
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fn abs_path(path: &Path) -> Result<PathBuf, String> {
|
|
93
|
+
if path.is_absolute() {
|
|
94
|
+
Ok(normalize_worktree_path(path))
|
|
95
|
+
} else {
|
|
96
|
+
let cwd = std::env::current_dir().map_err(|e| format!("failed to get cwd: {e}"))?;
|
|
97
|
+
Ok(normalize_worktree_path(&cwd.join(path)))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
fn assert_external_worktree_path(repo_root: &Path, worktree_path: &Path) -> Result<(), String> {
|
|
102
|
+
let repo = abs_path(repo_root)?;
|
|
103
|
+
let wt = abs_path(worktree_path)?;
|
|
104
|
+
if wt.starts_with(&repo) {
|
|
105
|
+
return Err(format!(
|
|
106
|
+
"Refusing to create worktree inside repo root (would recurse): repoRoot={} worktreePath={}",
|
|
107
|
+
repo.display(),
|
|
108
|
+
wt.display()
|
|
109
|
+
));
|
|
110
|
+
}
|
|
111
|
+
Ok(())
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fn is_git_worktree(dir: &Path) -> bool {
|
|
115
|
+
if !dir.exists() {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
let args = vec![
|
|
119
|
+
"-C".to_string(),
|
|
120
|
+
dir.display().to_string(),
|
|
121
|
+
"rev-parse".to_string(),
|
|
122
|
+
"--is-inside-work-tree".to_string(),
|
|
123
|
+
];
|
|
124
|
+
match run_git(&args, None) {
|
|
125
|
+
Ok(out) => out.ok && out.stdout.trim() == "true",
|
|
126
|
+
Err(_) => false,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
fn branch_exists(repo_root: &Path, branch: &str) -> bool {
|
|
131
|
+
let args = vec![
|
|
132
|
+
"-C".to_string(),
|
|
133
|
+
repo_root.display().to_string(),
|
|
134
|
+
"show-ref".to_string(),
|
|
135
|
+
"--verify".to_string(),
|
|
136
|
+
"--quiet".to_string(),
|
|
137
|
+
format!("refs/heads/{branch}"),
|
|
138
|
+
];
|
|
139
|
+
match run_git(&args, None) {
|
|
140
|
+
Ok(out) => out.status == 0,
|
|
141
|
+
Err(_) => false,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
fn ensure_branch_at(repo_root: &Path, branch: &str, base_sha: &str) -> Result<(), String> {
|
|
146
|
+
if branch_exists(repo_root, branch) {
|
|
147
|
+
return Ok(());
|
|
148
|
+
}
|
|
149
|
+
let args = vec![
|
|
150
|
+
"-C".to_string(),
|
|
151
|
+
repo_root.display().to_string(),
|
|
152
|
+
"branch".to_string(),
|
|
153
|
+
branch.to_string(),
|
|
154
|
+
base_sha.to_string(),
|
|
155
|
+
];
|
|
156
|
+
let out = run_git(&args, None)?;
|
|
157
|
+
if !out.ok {
|
|
158
|
+
return Err(out.stderr.is_empty().then(|| format!("git branch failed")).unwrap_or(out.stderr));
|
|
159
|
+
}
|
|
160
|
+
Ok(())
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#[derive(Deserialize)]
|
|
164
|
+
struct WorktreeEnsureIn {
|
|
165
|
+
repoRoot: String,
|
|
166
|
+
worktreePath: String,
|
|
167
|
+
branch: String,
|
|
168
|
+
baseSha: String,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#[derive(Serialize)]
|
|
172
|
+
struct WorktreeEnsureOut {
|
|
173
|
+
worktreePath: String,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
fn worktree_ensure(input: WorktreeEnsureIn) -> Result<WorktreeEnsureOut, String> {
|
|
177
|
+
let repo_root = PathBuf::from(input.repoRoot);
|
|
178
|
+
let worktree_path = PathBuf::from(input.worktreePath);
|
|
179
|
+
assert_external_worktree_path(&repo_root, &worktree_path)?;
|
|
180
|
+
ensure_branch_at(&repo_root, &input.branch, &input.baseSha)?;
|
|
181
|
+
|
|
182
|
+
if worktree_path.exists() {
|
|
183
|
+
if !is_git_worktree(&worktree_path) {
|
|
184
|
+
return Err(format!(
|
|
185
|
+
"Worktree path exists but is not a git worktree: {}",
|
|
186
|
+
worktree_path.display()
|
|
187
|
+
));
|
|
188
|
+
}
|
|
189
|
+
return Ok(WorktreeEnsureOut {
|
|
190
|
+
worktreePath: worktree_path.display().to_string(),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if let Some(parent) = worktree_path.parent() {
|
|
195
|
+
fs::create_dir_all(parent)
|
|
196
|
+
.map_err(|e| format!("failed to create worktree parent dir: {e}"))?;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let args = vec![
|
|
200
|
+
"-C".to_string(),
|
|
201
|
+
repo_root.display().to_string(),
|
|
202
|
+
"worktree".to_string(),
|
|
203
|
+
"add".to_string(),
|
|
204
|
+
worktree_path.display().to_string(),
|
|
205
|
+
input.branch,
|
|
206
|
+
];
|
|
207
|
+
let out = run_git(&args, None)?;
|
|
208
|
+
if !out.ok {
|
|
209
|
+
return Err(if out.stderr.is_empty() {
|
|
210
|
+
format!("git worktree add failed: {}", worktree_path.display())
|
|
211
|
+
} else {
|
|
212
|
+
out.stderr
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
Ok(WorktreeEnsureOut {
|
|
216
|
+
worktreePath: worktree_path.display().to_string(),
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#[derive(Deserialize)]
|
|
221
|
+
struct WorktreeRemoveIn {
|
|
222
|
+
repoRoot: String,
|
|
223
|
+
worktreePath: String,
|
|
224
|
+
#[serde(default)]
|
|
225
|
+
force: bool,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#[derive(Serialize)]
|
|
229
|
+
struct WorktreeRemoveOut {
|
|
230
|
+
ok: bool,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
fn worktree_remove(input: WorktreeRemoveIn) -> Result<WorktreeRemoveOut, String> {
|
|
234
|
+
let repo_root = PathBuf::from(input.repoRoot);
|
|
235
|
+
let worktree_path = PathBuf::from(input.worktreePath);
|
|
236
|
+
let mut args = vec![
|
|
237
|
+
"-C".to_string(),
|
|
238
|
+
repo_root.display().to_string(),
|
|
239
|
+
"worktree".to_string(),
|
|
240
|
+
"remove".to_string(),
|
|
241
|
+
];
|
|
242
|
+
if input.force {
|
|
243
|
+
args.push("--force".to_string());
|
|
244
|
+
}
|
|
245
|
+
args.push(worktree_path.display().to_string());
|
|
246
|
+
let out = run_git(&args, None)?;
|
|
247
|
+
if !out.ok {
|
|
248
|
+
return Err(if out.stderr.is_empty() {
|
|
249
|
+
format!("git worktree remove failed: {}", worktree_path.display())
|
|
250
|
+
} else {
|
|
251
|
+
out.stderr
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
Ok(WorktreeRemoveOut { ok: true })
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
fn normalize_repo_path(p: &str) -> Option<String> {
|
|
258
|
+
let posix = p.replace('\\', "/");
|
|
259
|
+
if posix.starts_with('/') {
|
|
260
|
+
return None;
|
|
261
|
+
}
|
|
262
|
+
let bytes = posix.as_bytes();
|
|
263
|
+
if bytes.len() >= 3 {
|
|
264
|
+
let c0 = bytes[0];
|
|
265
|
+
let c1 = bytes[1];
|
|
266
|
+
let c2 = bytes[2];
|
|
267
|
+
if (c0 as char).is_ascii_alphabetic() && c1 == b':' && c2 == b'/' {
|
|
268
|
+
return None;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let mut stack: Vec<&str> = Vec::new();
|
|
273
|
+
for part in posix.split('/') {
|
|
274
|
+
if part.is_empty() || part == "." {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if part == ".." {
|
|
278
|
+
if stack.is_empty() {
|
|
279
|
+
return None;
|
|
280
|
+
}
|
|
281
|
+
stack.pop();
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
stack.push(part);
|
|
285
|
+
}
|
|
286
|
+
if stack.is_empty() {
|
|
287
|
+
return None;
|
|
288
|
+
}
|
|
289
|
+
Some(stack.join("/"))
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
fn touched_files_from_unified_diff(patch_path: &Path) -> Result<Vec<(String, bool)>, String> {
|
|
293
|
+
let f = File::open(patch_path)
|
|
294
|
+
.map_err(|e| format!("failed to open patch for parsing: {}: {e}", patch_path.display()))?;
|
|
295
|
+
let reader = BufReader::new(f);
|
|
296
|
+
|
|
297
|
+
let mut files: Vec<(String, bool)> = Vec::new();
|
|
298
|
+
let mut seen: BTreeSet<String> = BTreeSet::new();
|
|
299
|
+
|
|
300
|
+
for line in reader.lines() {
|
|
301
|
+
let line = line.map_err(|e| format!("failed reading patch: {e}"))?;
|
|
302
|
+
if !line.starts_with("diff --git ") {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
// Typical: diff --git a/foo/bar b/foo/bar
|
|
306
|
+
let rest = line.trim_start_matches("diff --git ").trim();
|
|
307
|
+
let mut it = rest.split_whitespace();
|
|
308
|
+
let a = it.next();
|
|
309
|
+
let b = it.next();
|
|
310
|
+
if a.is_none() || b.is_none() {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
let a_path = a.unwrap().strip_prefix("a/").unwrap_or(a.unwrap());
|
|
314
|
+
let b_path = b.unwrap().strip_prefix("b/").unwrap_or(b.unwrap());
|
|
315
|
+
|
|
316
|
+
let file = if b_path == "/dev/null" { a_path } else { b_path };
|
|
317
|
+
match normalize_repo_path(file) {
|
|
318
|
+
Some(n) => {
|
|
319
|
+
if seen.contains(&n) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
seen.insert(n.clone());
|
|
323
|
+
files.push((n, false));
|
|
324
|
+
}
|
|
325
|
+
None => {
|
|
326
|
+
files.push((file.to_string(), true));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
Ok(files)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
fn ensure_owned(touched_files: &[(String, bool)], allowed_prefixes: &[String]) -> Result<(), String> {
|
|
334
|
+
let mut allowed: Vec<String> = allowed_prefixes
|
|
335
|
+
.iter()
|
|
336
|
+
.map(|p| p.replace('\\', "/"))
|
|
337
|
+
.filter(|p| !p.trim().is_empty())
|
|
338
|
+
.map(|p| if p.ends_with('/') { p } else { format!("{p}/") })
|
|
339
|
+
.collect();
|
|
340
|
+
allowed.sort();
|
|
341
|
+
allowed.dedup();
|
|
342
|
+
|
|
343
|
+
if allowed.is_empty() {
|
|
344
|
+
return Err("allowedPathPrefixes is empty".to_string());
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
let mut violations: Vec<String> = Vec::new();
|
|
348
|
+
for (path, invalid) in touched_files.iter() {
|
|
349
|
+
if *invalid {
|
|
350
|
+
violations.push(format!("invalid path in patch: {path}"));
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
let mut ok = false;
|
|
354
|
+
for prefix in allowed.iter() {
|
|
355
|
+
let base = prefix.trim_end_matches('/');
|
|
356
|
+
if path == base || path.starts_with(prefix) {
|
|
357
|
+
ok = true;
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if !ok {
|
|
362
|
+
violations.push(format!("unauthorized path: {path}"));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if !violations.is_empty() {
|
|
367
|
+
return Err(format!(
|
|
368
|
+
"patch ownership check failed:\n- {}",
|
|
369
|
+
violations.join("\n- ")
|
|
370
|
+
));
|
|
371
|
+
}
|
|
372
|
+
Ok(())
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
#[derive(Deserialize)]
|
|
376
|
+
struct PatchApplyIn {
|
|
377
|
+
worktreePath: String,
|
|
378
|
+
patchPath: String,
|
|
379
|
+
allowedPathPrefixes: Vec<String>,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
#[derive(Serialize)]
|
|
383
|
+
struct PatchApplyOut {
|
|
384
|
+
touchedFiles: Vec<String>,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
fn patch_apply(input: PatchApplyIn) -> Result<PatchApplyOut, String> {
|
|
388
|
+
let worktree_path = PathBuf::from(input.worktreePath);
|
|
389
|
+
let patch_path = PathBuf::from(input.patchPath);
|
|
390
|
+
|
|
391
|
+
let patch_text = fs::read_to_string(&patch_path)
|
|
392
|
+
.map_err(|e| format!("failed to read patch file: {}: {e}", patch_path.display()))?;
|
|
393
|
+
let trimmed = patch_text.trim();
|
|
394
|
+
if trimmed.is_empty() {
|
|
395
|
+
return Ok(PatchApplyOut {
|
|
396
|
+
touchedFiles: Vec::new(),
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let touched = touched_files_from_unified_diff(&patch_path)?;
|
|
401
|
+
if touched.is_empty() {
|
|
402
|
+
return Err("patch has content but no \"diff --git\" headers (not a unified diff?)".to_string());
|
|
403
|
+
}
|
|
404
|
+
ensure_owned(&touched, &input.allowedPathPrefixes)?;
|
|
405
|
+
|
|
406
|
+
let args_check = vec![
|
|
407
|
+
"-C".to_string(),
|
|
408
|
+
worktree_path.display().to_string(),
|
|
409
|
+
"apply".to_string(),
|
|
410
|
+
"--check".to_string(),
|
|
411
|
+
patch_path.display().to_string(),
|
|
412
|
+
];
|
|
413
|
+
let out = run_git(&args_check, None)?;
|
|
414
|
+
if !out.ok {
|
|
415
|
+
return Err(if out.stderr.is_empty() {
|
|
416
|
+
"git apply --check failed".to_string()
|
|
417
|
+
} else {
|
|
418
|
+
out.stderr
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
let args_apply = vec![
|
|
423
|
+
"-C".to_string(),
|
|
424
|
+
worktree_path.display().to_string(),
|
|
425
|
+
"apply".to_string(),
|
|
426
|
+
patch_path.display().to_string(),
|
|
427
|
+
];
|
|
428
|
+
let out2 = run_git(&args_apply, None)?;
|
|
429
|
+
if !out2.ok {
|
|
430
|
+
return Err(if out2.stderr.is_empty() {
|
|
431
|
+
"git apply failed".to_string()
|
|
432
|
+
} else {
|
|
433
|
+
out2.stderr
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
Ok(PatchApplyOut {
|
|
438
|
+
touchedFiles: touched
|
|
439
|
+
.into_iter()
|
|
440
|
+
.filter(|(_, invalid)| !*invalid)
|
|
441
|
+
.map(|(p, _)| p)
|
|
442
|
+
.collect(),
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
#[derive(Deserialize)]
|
|
447
|
+
struct CommitAllIn {
|
|
448
|
+
repoRoot: String,
|
|
449
|
+
message: String,
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
#[derive(Serialize)]
|
|
453
|
+
struct CommitAllOut {
|
|
454
|
+
sha: String,
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
fn commit_all(input: CommitAllIn) -> Result<CommitAllOut, String> {
|
|
458
|
+
let repo_root = PathBuf::from(input.repoRoot);
|
|
459
|
+
|
|
460
|
+
let out_add = run_git(
|
|
461
|
+
&vec![
|
|
462
|
+
"-C".to_string(),
|
|
463
|
+
repo_root.display().to_string(),
|
|
464
|
+
"add".to_string(),
|
|
465
|
+
"-A".to_string(),
|
|
466
|
+
],
|
|
467
|
+
None,
|
|
468
|
+
)?;
|
|
469
|
+
if !out_add.ok {
|
|
470
|
+
return Err(out_add.stderr.is_empty().then(|| "git add failed".to_string()).unwrap_or(out_add.stderr));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
let out_commit = run_git(
|
|
474
|
+
&vec![
|
|
475
|
+
"-C".to_string(),
|
|
476
|
+
repo_root.display().to_string(),
|
|
477
|
+
"commit".to_string(),
|
|
478
|
+
"-m".to_string(),
|
|
479
|
+
input.message,
|
|
480
|
+
],
|
|
481
|
+
None,
|
|
482
|
+
)?;
|
|
483
|
+
if !out_commit.ok {
|
|
484
|
+
return Err(out_commit.stderr.is_empty().then(|| "git commit failed".to_string()).unwrap_or(out_commit.stderr));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
let out_sha = run_git(
|
|
488
|
+
&vec![
|
|
489
|
+
"-C".to_string(),
|
|
490
|
+
repo_root.display().to_string(),
|
|
491
|
+
"rev-parse".to_string(),
|
|
492
|
+
"HEAD".to_string(),
|
|
493
|
+
],
|
|
494
|
+
None,
|
|
495
|
+
)?;
|
|
496
|
+
if !out_sha.ok {
|
|
497
|
+
return Err(out_sha.stderr.is_empty().then(|| "git rev-parse HEAD failed".to_string()).unwrap_or(out_sha.stderr));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
Ok(CommitAllOut {
|
|
501
|
+
sha: out_sha.stdout.trim().to_string(),
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
#[derive(Deserialize)]
|
|
506
|
+
struct VerifyCmdIn {
|
|
507
|
+
name: String,
|
|
508
|
+
command: String,
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
#[derive(Deserialize)]
|
|
512
|
+
struct VerifyRunIn {
|
|
513
|
+
worktreePath: String,
|
|
514
|
+
outDir: String,
|
|
515
|
+
commands: Vec<VerifyCmdIn>,
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
#[derive(Serialize)]
|
|
519
|
+
struct VerifyCmdOut {
|
|
520
|
+
name: String,
|
|
521
|
+
command: String,
|
|
522
|
+
ok: bool,
|
|
523
|
+
exitCode: i32,
|
|
524
|
+
outputPath: String,
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
#[derive(Serialize)]
|
|
528
|
+
struct VerifySummaryOut {
|
|
529
|
+
version: i32,
|
|
530
|
+
ranAt: String,
|
|
531
|
+
commands: Vec<VerifyCmdOut>,
|
|
532
|
+
ok: bool,
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
fn safe_name(name: &str) -> String {
|
|
536
|
+
let mut out = String::new();
|
|
537
|
+
let mut last_was_dash = false;
|
|
538
|
+
for ch in name.trim().to_lowercase().chars() {
|
|
539
|
+
let allowed = ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-';
|
|
540
|
+
if allowed {
|
|
541
|
+
out.push(ch);
|
|
542
|
+
last_was_dash = false;
|
|
543
|
+
} else if !last_was_dash {
|
|
544
|
+
out.push('-');
|
|
545
|
+
last_was_dash = true;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
let out = out.trim_matches('-').to_string();
|
|
549
|
+
if out.is_empty() {
|
|
550
|
+
"command".to_string()
|
|
551
|
+
} else {
|
|
552
|
+
out
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
fn run_shell_command_to_file(command: &str, cwd: &Path, output_path: &Path) -> Result<i32, String> {
|
|
557
|
+
let file = File::create(output_path)
|
|
558
|
+
.map_err(|e| format!("failed to create output file {}: {e}", output_path.display()))?;
|
|
559
|
+
let file_err = file
|
|
560
|
+
.try_clone()
|
|
561
|
+
.map_err(|e| format!("failed to clone output file handle: {e}"))?;
|
|
562
|
+
|
|
563
|
+
let mut cmd;
|
|
564
|
+
if cfg!(windows) {
|
|
565
|
+
cmd = Command::new("cmd");
|
|
566
|
+
cmd.arg("/C").arg(command);
|
|
567
|
+
} else {
|
|
568
|
+
cmd = Command::new("sh");
|
|
569
|
+
cmd.arg("-lc").arg(command);
|
|
570
|
+
}
|
|
571
|
+
let status = cmd
|
|
572
|
+
.current_dir(cwd)
|
|
573
|
+
.stdin(Stdio::null())
|
|
574
|
+
.stdout(Stdio::from(file))
|
|
575
|
+
.stderr(Stdio::from(file_err))
|
|
576
|
+
.status()
|
|
577
|
+
.map_err(|e| format!("failed to run command: {e}"))?;
|
|
578
|
+
|
|
579
|
+
Ok(status.code().unwrap_or(1))
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
fn verify_run(input: VerifyRunIn) -> Result<VerifySummaryOut, String> {
|
|
583
|
+
let worktree = PathBuf::from(input.worktreePath);
|
|
584
|
+
let out_dir = PathBuf::from(input.outDir);
|
|
585
|
+
fs::create_dir_all(&out_dir)
|
|
586
|
+
.map_err(|e| format!("failed to create verify outDir {}: {e}", out_dir.display()))?;
|
|
587
|
+
|
|
588
|
+
let mut results: Vec<VerifyCmdOut> = Vec::new();
|
|
589
|
+
let mut all_ok = true;
|
|
590
|
+
|
|
591
|
+
for c in input.commands.iter() {
|
|
592
|
+
let name_safe = safe_name(&c.name);
|
|
593
|
+
let output_path = out_dir.join(format!("{name_safe}.txt"));
|
|
594
|
+
|
|
595
|
+
let exit_code = run_shell_command_to_file(&c.command, &worktree, &output_path)?;
|
|
596
|
+
let ok = exit_code == 0;
|
|
597
|
+
if !ok {
|
|
598
|
+
all_ok = false;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
results.push(VerifyCmdOut {
|
|
602
|
+
name: c.name.clone(),
|
|
603
|
+
command: c.command.clone(),
|
|
604
|
+
ok,
|
|
605
|
+
exitCode: exit_code,
|
|
606
|
+
outputPath: output_path.display().to_string(),
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
let summary = VerifySummaryOut {
|
|
611
|
+
version: 1,
|
|
612
|
+
ranAt: now_iso(),
|
|
613
|
+
commands: results,
|
|
614
|
+
ok: all_ok,
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// Write evidence file.
|
|
618
|
+
let summary_path = out_dir.join("summary.json");
|
|
619
|
+
let mut f = File::create(&summary_path)
|
|
620
|
+
.map_err(|e| format!("failed to write {}: {e}", summary_path.display()))?;
|
|
621
|
+
let json = serde_json::to_string_pretty(&summary)
|
|
622
|
+
.map_err(|e| format!("failed to serialize verify summary: {e}"))?;
|
|
623
|
+
f.write_all(json.as_bytes())
|
|
624
|
+
.and_then(|_| f.write_all(b"\n"))
|
|
625
|
+
.map_err(|e| format!("failed to write verify summary: {e}"))?;
|
|
626
|
+
|
|
627
|
+
Ok(summary)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
fn usage() {
|
|
631
|
+
eprintln!(
|
|
632
|
+
r#"ecc-kernel
|
|
633
|
+
|
|
634
|
+
Usage:
|
|
635
|
+
ecc-kernel <command> (JSON input on stdin; JSON output on stdout)
|
|
636
|
+
|
|
637
|
+
Commands:
|
|
638
|
+
worktree.ensure
|
|
639
|
+
worktree.remove
|
|
640
|
+
patch.apply
|
|
641
|
+
git.commit_all
|
|
642
|
+
verify.run
|
|
643
|
+
"#
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
fn real_main() -> Result<(), String> {
|
|
648
|
+
let mut args = std::env::args();
|
|
649
|
+
let _ = args.next();
|
|
650
|
+
let cmd = match args.next() {
|
|
651
|
+
Some(c) => c,
|
|
652
|
+
None => {
|
|
653
|
+
usage();
|
|
654
|
+
return Err("missing command".to_string());
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
if cmd == "--help" || cmd == "-h" {
|
|
659
|
+
usage();
|
|
660
|
+
return Ok(());
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if cmd == "--version" || cmd == "-V" {
|
|
664
|
+
println!("ecc-kernel {}", env!("CARGO_PKG_VERSION"));
|
|
665
|
+
return Ok(());
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
let result = match cmd.as_str() {
|
|
669
|
+
"worktree.ensure" => {
|
|
670
|
+
let input: WorktreeEnsureIn = read_stdin_json()?;
|
|
671
|
+
let out = worktree_ensure(input)?;
|
|
672
|
+
write_stdout_json(&out)
|
|
673
|
+
}
|
|
674
|
+
"worktree.remove" => {
|
|
675
|
+
let input: WorktreeRemoveIn = read_stdin_json()?;
|
|
676
|
+
let out = worktree_remove(input)?;
|
|
677
|
+
write_stdout_json(&out)
|
|
678
|
+
}
|
|
679
|
+
"patch.apply" => {
|
|
680
|
+
let input: PatchApplyIn = read_stdin_json()?;
|
|
681
|
+
let out = patch_apply(input)?;
|
|
682
|
+
write_stdout_json(&out)
|
|
683
|
+
}
|
|
684
|
+
"git.commit_all" => {
|
|
685
|
+
let input: CommitAllIn = read_stdin_json()?;
|
|
686
|
+
let out = commit_all(input)?;
|
|
687
|
+
write_stdout_json(&out)
|
|
688
|
+
}
|
|
689
|
+
"verify.run" => {
|
|
690
|
+
let input: VerifyRunIn = read_stdin_json()?;
|
|
691
|
+
let out = verify_run(input)?;
|
|
692
|
+
write_stdout_json(&out)
|
|
693
|
+
}
|
|
694
|
+
_ => Err(format!("unknown command: {cmd}")),
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
result
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
fn main() -> ExitCode {
|
|
701
|
+
match real_main() {
|
|
702
|
+
Ok(()) => ExitCode::from(0),
|
|
703
|
+
Err(err) => {
|
|
704
|
+
if err != "missing command" {
|
|
705
|
+
eprintln!("{err}");
|
|
706
|
+
}
|
|
707
|
+
ExitCode::from(1)
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|