4 Commits

Author SHA1 Message Date
Affaan Mustafa
99a60c0a66 fix: make npm pack test shell-safe on windows 2026-04-12 22:19:16 -07:00
Affaan Mustafa
c40b883413 fix: prefer repo-relative hook file paths 2026-04-12 22:13:16 -07:00
Affaan Mustafa
992bdfd6e0 fix: persist detached runner startup stderr 2026-04-12 22:10:06 -07:00
Affaan Mustafa
f230ee428f fix: stabilize windows ci portability 2026-04-12 22:06:04 -07:00
4 changed files with 174 additions and 45 deletions

View File

@@ -4,6 +4,7 @@ use cron::Schedule as CronSchedule;
use serde::Serialize;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt;
use std::fs::OpenOptions;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::str::FromStr;
@@ -2983,6 +2984,26 @@ async fn spawn_session_runner_for_program(
working_dir: &Path,
current_exe: &Path,
) -> Result<()> {
let stderr_log_path = background_runner_stderr_log_path(working_dir, session_id);
if let Some(parent) = stderr_log_path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create ECC runner log directory {}",
parent.display()
)
})?;
}
let stderr_log = OpenOptions::new()
.create(true)
.append(true)
.open(&stderr_log_path)
.with_context(|| {
format!(
"Failed to open ECC runner stderr log {}",
stderr_log_path.display()
)
})?;
let mut command = Command::new(current_exe);
command
.arg("run-session")
@@ -2996,7 +3017,7 @@ async fn spawn_session_runner_for_program(
.arg(working_dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
.stderr(Stdio::from(stderr_log));
configure_background_runner_command(&mut command);
let child = command
@@ -3009,6 +3030,21 @@ async fn spawn_session_runner_for_program(
Ok(())
}
fn background_runner_stderr_log_path(working_dir: &Path, session_id: &str) -> PathBuf {
working_dir
.join(".claude")
.join("ecc2")
.join("logs")
.join(format!("{session_id}.runner-stderr.log"))
}
#[cfg(windows)]
fn detached_creation_flags() -> u32 {
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
}
fn configure_background_runner_command(command: &mut Command) {
#[cfg(unix)]
{
@@ -3030,11 +3066,7 @@ fn configure_background_runner_command(command: &mut Command) {
{
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
command
.as_std_mut()
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
command.as_std_mut().creation_flags(detached_creation_flags());
}
}
@@ -5137,6 +5169,22 @@ mod tests {
Ok(())
}
#[test]
fn background_runner_stderr_log_path_is_session_scoped() {
let path =
background_runner_stderr_log_path(Path::new("/tmp/ecc-repo"), "session-123");
assert_eq!(
path,
PathBuf::from("/tmp/ecc-repo/.claude/ecc2/logs/session-123.runner-stderr.log")
);
}
#[cfg(windows)]
#[test]
fn detached_creation_flags_include_detach_and_process_group() {
assert_eq!(detached_creation_flags(), 0x0000_0008 | 0x0000_0200);
}
fn write_package_manager_project_files(
repo_root: &Path,
package_manager_field: Option<&str>,

View File

@@ -359,44 +359,72 @@ function gitRepoRoot(cwd) {
return runGit(['rev-parse', '--show-toplevel'], cwd);
}
function repoRelativePath(repoRoot, filePath) {
const absolute = path.isAbsolute(filePath)
? path.resolve(filePath)
: path.resolve(process.cwd(), filePath);
const relative = path.relative(repoRoot, absolute);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
return null;
const MAX_RELEVANT_PATCH_LINES = 6;
function candidateGitPaths(repoRoot, filePath) {
const resolvedRepoRoot = path.resolve(repoRoot);
const candidates = [];
const pushCandidate = value => {
const candidate = String(value || '').trim();
if (!candidate || candidates.includes(candidate)) {
return;
}
candidates.push(candidate);
};
const absoluteCandidates = path.isAbsolute(filePath)
? [path.resolve(filePath)]
: [
path.resolve(resolvedRepoRoot, filePath),
path.resolve(process.cwd(), filePath),
];
for (const absolute of absoluteCandidates) {
const relative = path.relative(resolvedRepoRoot, absolute);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
continue;
}
pushCandidate(relative);
pushCandidate(relative.split(path.sep).join('/'));
pushCandidate(absolute);
pushCandidate(absolute.split(path.sep).join('/'));
}
return relative.split(path.sep).join('/');
return candidates;
}
function patchPreviewFromGitDiff(repoRoot, repoRelative) {
const patch = runGit(
['diff', '--no-ext-diff', '--no-color', '--unified=1', '--', repoRelative],
repoRoot
function patchPreviewFromGitDiff(repoRoot, pathCandidates) {
for (const candidate of pathCandidates) {
const patch = runGit(
['diff', '--no-ext-diff', '--no-color', '--unified=1', '--', candidate],
repoRoot
);
if (!patch) {
continue;
}
const relevant = patch
.split(/\r?\n/)
.filter(line =>
line.startsWith('@@')
|| (line.startsWith('+') && !line.startsWith('+++'))
|| (line.startsWith('-') && !line.startsWith('---'))
)
.slice(0, MAX_RELEVANT_PATCH_LINES);
if (relevant.length > 0) {
return relevant.join('\n');
}
}
return undefined;
}
function trackedInGit(repoRoot, pathCandidates) {
return pathCandidates.some(candidate =>
runGit(['ls-files', '--error-unmatch', '--', candidate], repoRoot) !== null
);
if (!patch) {
return undefined;
}
const relevant = patch
.split(/\r?\n/)
.filter(line =>
line.startsWith('@@')
|| (line.startsWith('+') && !line.startsWith('+++'))
|| (line.startsWith('-') && !line.startsWith('---'))
)
.slice(0, 6);
if (relevant.length === 0) {
return undefined;
}
return relevant.join('\n');
}
function trackedInGit(repoRoot, repoRelative) {
return runGit(['ls-files', '--error-unmatch', '--', repoRelative], repoRoot) !== null;
}
function enrichFileEventFromWorkingTree(toolName, event) {
@@ -409,14 +437,14 @@ function enrichFileEventFromWorkingTree(toolName, event) {
return event;
}
const repoRelative = repoRelativePath(repoRoot, event.path);
if (!repoRelative) {
const pathCandidates = candidateGitPaths(repoRoot, event.path);
if (pathCandidates.length === 0) {
return event;
}
const tool = String(toolName || '').trim().toLowerCase();
const tracked = trackedInGit(repoRoot, repoRelative);
const patchPreview = patchPreviewFromGitDiff(repoRoot, repoRelative) || event.patch_preview;
const tracked = trackedInGit(repoRoot, pathCandidates);
const patchPreview = patchPreviewFromGitDiff(repoRoot, pathCandidates) || event.patch_preview;
const diffPreview = buildDiffPreviewFromPatchPreview(patchPreview) || event.diff_preview;
if (tool.includes('write')) {

View File

@@ -309,6 +309,58 @@ function runTests() {
fs.rmSync(repoDir, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('resolves repo-relative paths even when the hook runs from a nested cwd', () => {
const tmpHome = makeTempDir();
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-nested-repo-'));
spawnSync('git', ['init'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['config', 'user.email', 'ecc@example.com'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['config', 'user.name', 'ECC Tests'], { cwd: repoDir, encoding: 'utf8' });
const srcDir = path.join(repoDir, 'src');
const nestedCwd = path.join(repoDir, 'subdir');
fs.mkdirSync(srcDir, { recursive: true });
fs.mkdirSync(nestedCwd, { recursive: true });
const trackedFile = path.join(srcDir, 'app.ts');
fs.writeFileSync(trackedFile, 'const count = 1;\n', 'utf8');
spawnSync('git', ['add', 'src/app.ts'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, encoding: 'utf8' });
fs.writeFileSync(trackedFile, 'const count = 2;\n', 'utf8');
const input = {
tool_name: 'Write',
tool_input: {
file_path: 'src/app.ts',
content: 'const count = 2;\n',
},
tool_output: { output: 'updated src/app.ts' },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'ecc-session-nested-cwd',
}, {
cwd: nestedCwd,
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.deepStrictEqual(row.file_events, [
{
path: 'src/app.ts',
action: 'modify',
diff_preview: 'const count = 1; -> const count = 2;',
patch_preview: '@@ -1 +1 @@\n-const count = 1;\n+const count = 2;',
},
]);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(repoDir, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID and redacts bash summaries', () => {
const tmpHome = makeTempDir();
const input = {

View File

@@ -49,8 +49,9 @@ function main() {
const result = spawnSync("npm", ["pack", "--dry-run", "--json"], {
cwd: repoRoot,
encoding: "utf8",
shell: process.platform === "win32",
})
assert.strictEqual(result.status, 0, result.stderr)
assert.strictEqual(result.status, 0, result.error?.message || result.stderr)
const packOutput = JSON.parse(result.stdout)
const packagedPaths = new Set(packOutput[0]?.files?.map((file) => file.path) ?? [])