mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
fix(continuous-learning-v2): honor non-git CLAUDE_PROJECT_DIR (#2488)
Honor an explicit non-git CLAUDE_PROJECT_DIR as an isolated project scope, canonicalize and hash it consistently in the shell observer and Python CLI, and preserve the global fallback for arbitrary non-git working directories.
This commit is contained in:
@@ -128,7 +128,7 @@ Session Activity (in a git repo)
|
||||
|
||||
The system automatically detects your current project:
|
||||
|
||||
1. **`CLAUDE_PROJECT_DIR` env var** (highest priority)
|
||||
1. **`CLAUDE_PROJECT_DIR` env var** (highest priority) -- honored as an explicit override even when the directory is not a git repo (hashed by its absolute path)
|
||||
2. **`git remote get-url origin`** -- hashed to create a portable project ID (same repo on different machines gets the same ID)
|
||||
3. **`git rev-parse --show-toplevel`** -- fallback using repo path (machine-specific)
|
||||
4. **Global fallback** -- if no project is detected, instincts go to global scope
|
||||
|
||||
@@ -105,11 +105,23 @@ _clv2_detect_project() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 1. Try CLAUDE_PROJECT_DIR env var
|
||||
if [ -n "$CLAUDE_PROJECT_DIR" ] && [ -d "$CLAUDE_PROJECT_DIR" ] && command -v git &>/dev/null; then
|
||||
project_root=$(git -C "$CLAUDE_PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null || true)
|
||||
if [ -n "$project_root" ]; then
|
||||
source_hint="env"
|
||||
# 1. Try CLAUDE_PROJECT_DIR env var (explicit override)
|
||||
if [ -n "$CLAUDE_PROJECT_DIR" ] && [ -d "$CLAUDE_PROJECT_DIR" ]; then
|
||||
if command -v git &>/dev/null; then
|
||||
project_root=$(git -C "$CLAUDE_PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null || true)
|
||||
if [ -n "$project_root" ]; then
|
||||
source_hint="env"
|
||||
fi
|
||||
fi
|
||||
# Non-git directory explicitly pointed at by CLAUDE_PROJECT_DIR: honor it as
|
||||
# a project root (path-hash identity) rather than collapsing to the shared
|
||||
# `global` bucket. Gated on the explicit env var so an arbitrary non-git cwd
|
||||
# never becomes a "project" — priority 2 below stays git-only on purpose.
|
||||
if [ -z "$project_root" ]; then
|
||||
project_root=$(cd "$CLAUDE_PROJECT_DIR" 2>/dev/null && pwd -P)
|
||||
if [ -n "$project_root" ]; then
|
||||
source_hint="env-nogit"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -298,10 +298,19 @@ def detect_project() -> dict:
|
||||
"observations_file": GLOBAL_OBSERVATIONS_FILE,
|
||||
}
|
||||
|
||||
# 1. CLAUDE_PROJECT_DIR env var
|
||||
# 1. CLAUDE_PROJECT_DIR env var (explicit override)
|
||||
env_dir = os.environ.get("CLAUDE_PROJECT_DIR")
|
||||
if env_dir and os.path.isdir(env_dir):
|
||||
project_root = _git_repo_root(env_dir)
|
||||
# Non-git directory explicitly pointed at by CLAUDE_PROJECT_DIR: honor it
|
||||
# as a project root (path-hash identity) rather than collapsing to the
|
||||
# shared `global` bucket. Mirrors detect-project.sh so the observer
|
||||
# (shell) and this CLI agree on the project id for the same directory;
|
||||
# os.path.realpath matches the shell's `cd ... && pwd -P`. Gated on the
|
||||
# explicit env var so an arbitrary non-git cwd (priority 2) never
|
||||
# becomes a "project".
|
||||
if not project_root:
|
||||
project_root = os.path.realpath(env_dir)
|
||||
|
||||
# 2. git repo root
|
||||
if not project_root:
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Tests for non-git CLAUDE_PROJECT_DIR project detection (issue #2469)
|
||||
*
|
||||
* Validates that detect-project.sh honors an explicitly-provided
|
||||
* CLAUDE_PROJECT_DIR that is NOT a git repository, deriving a stable
|
||||
* path-based PROJECT_ID instead of collapsing to the shared `global`
|
||||
* bucket. A bare non-git cwd (no CLAUDE_PROJECT_DIR) must still fall
|
||||
* back to `global` — the fix is gated on the explicit env var so an
|
||||
* arbitrary working directory never becomes a "project".
|
||||
*
|
||||
* Run with: node tests/hooks/detect-project-nongit.test.js
|
||||
*/
|
||||
|
||||
// Skip on Windows — these tests invoke bash scripts directly
|
||||
if (process.platform === 'win32') {
|
||||
console.log('Skipping bash-dependent non-git detection tests on Windows\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
// Locate a Python interpreter for the cross-implementation consistency check.
|
||||
// Absent Python just skips that one case (the bash path is the primary fix).
|
||||
function findPython() {
|
||||
for (const bin of ['python3', 'python']) {
|
||||
const r = spawnSync(bin, ['--version'], { encoding: 'utf8' });
|
||||
if (!r.error && r.status === 0) return bin;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const PYTHON = findPython();
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passed++;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nongit-test-'));
|
||||
}
|
||||
|
||||
function cleanupDir(dir) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
const detectProjectPath = path.join(
|
||||
repoRoot,
|
||||
'skills',
|
||||
'continuous-learning-v2',
|
||||
'scripts',
|
||||
'detect-project.sh'
|
||||
);
|
||||
const instinctCliPath = path.join(
|
||||
repoRoot,
|
||||
'skills',
|
||||
'continuous-learning-v2',
|
||||
'scripts',
|
||||
'instinct-cli.py'
|
||||
);
|
||||
|
||||
// Resolve the project id the Python CLI (instinct-cli.py) assigns, so we can
|
||||
// prove it agrees with the shell observer for the same non-git directory.
|
||||
function pythonProjectId(projectDir, homeDir) {
|
||||
const code =
|
||||
'import importlib.util as u, sys;' +
|
||||
`s=u.spec_from_file_location("icli", ${JSON.stringify(instinctCliPath)});` +
|
||||
'm=u.module_from_spec(s); s.loader.exec_module(m);' +
|
||||
'print(m.detect_project()["id"])';
|
||||
const r = spawnSync(PYTHON, ['-c', code], {
|
||||
cwd: projectDir,
|
||||
timeout: 10000,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
CLAUDE_PROJECT_DIR: projectDir,
|
||||
},
|
||||
});
|
||||
if (r.status !== 0) {
|
||||
throw new Error(`instinct-cli.py detect_project failed: ${r.stderr || r.error}`);
|
||||
}
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
// Source detect-project.sh with an isolated HOME and the given
|
||||
// CLAUDE_PROJECT_DIR, returning the exported PROJECT_* vars.
|
||||
function detect(projectDir, homeDir, { setProjectDir = true } = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
};
|
||||
if (setProjectDir) {
|
||||
env.CLAUDE_PROJECT_DIR = projectDir;
|
||||
} else {
|
||||
delete env.CLAUDE_PROJECT_DIR;
|
||||
}
|
||||
|
||||
// Suppress only stdout; keep stderr so a sourcing failure (e.g. a syntax
|
||||
// error or missing _CLV2_PYTHON_CMD) surfaces instead of silently leaving
|
||||
// the PROJECT_* vars empty and producing confusing assertion messages.
|
||||
const script = `
|
||||
source "${detectProjectPath}" >/dev/null
|
||||
printf 'PROJECT_ID=%s\\n' "$PROJECT_ID"
|
||||
printf 'PROJECT_NAME=%s\\n' "$PROJECT_NAME"
|
||||
printf 'PROJECT_ROOT=%s\\n' "$PROJECT_ROOT"
|
||||
`;
|
||||
|
||||
const out = execFileSync('bash', ['-lc', script], {
|
||||
// Run from the project dir; it is not a git repo, so the cwd-git
|
||||
// branch (priority 2) cannot fire and interfere with the assertions.
|
||||
cwd: projectDir,
|
||||
timeout: 10000,
|
||||
env,
|
||||
}).toString();
|
||||
|
||||
const vars = {};
|
||||
for (const line of out.trim().split('\n')) {
|
||||
const m = line.match(/^(PROJECT_ID|PROJECT_NAME|PROJECT_ROOT)=(.*)$/);
|
||||
if (m) vars[m[1]] = m[2];
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
console.log('\n=== Non-git CLAUDE_PROJECT_DIR detection (issue #2469) ===\n');
|
||||
|
||||
console.log('--- Content check ---');
|
||||
|
||||
test('detect-project.sh has an env-nogit fallback for non-git dirs', () => {
|
||||
const content = fs.readFileSync(detectProjectPath, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('env-nogit'),
|
||||
'detect-project.sh should set source_hint="env-nogit" for non-git CLAUDE_PROJECT_DIR'
|
||||
);
|
||||
});
|
||||
|
||||
console.log('\n--- Behavior: non-git CLAUDE_PROJECT_DIR ---');
|
||||
|
||||
test('non-git CLAUDE_PROJECT_DIR yields a non-global, path-derived PROJECT_ID', () => {
|
||||
const testDir = createTempDir();
|
||||
try {
|
||||
const homeDir = path.join(testDir, 'home');
|
||||
const projectDir = path.join(testDir, 'my-plain-project');
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.git')), 'guard: project dir must not be a git repo');
|
||||
|
||||
const vars = detect(projectDir, homeDir);
|
||||
assert.ok(
|
||||
vars.PROJECT_ID && vars.PROJECT_ID !== 'global',
|
||||
`PROJECT_ID should not be "global", got: "${vars.PROJECT_ID || ''}"`
|
||||
);
|
||||
assert.ok(
|
||||
/^[0-9a-f]{12}$/.test(vars.PROJECT_ID),
|
||||
`PROJECT_ID should be a 12-char hex hash, got: "${vars.PROJECT_ID || ''}"`
|
||||
);
|
||||
assert.strictEqual(
|
||||
vars.PROJECT_NAME,
|
||||
'my-plain-project',
|
||||
`PROJECT_NAME should be the directory basename, got: "${vars.PROJECT_NAME || ''}"`
|
||||
);
|
||||
assert.strictEqual(
|
||||
vars.PROJECT_ROOT,
|
||||
fs.realpathSync(projectDir),
|
||||
`PROJECT_ROOT should be the canonicalized project dir, got: "${vars.PROJECT_ROOT || ''}"`
|
||||
);
|
||||
} finally {
|
||||
cleanupDir(testDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('PROJECT_ID is stable across repeated invocations of the same dir', () => {
|
||||
const testDir = createTempDir();
|
||||
try {
|
||||
const homeDir = path.join(testDir, 'home');
|
||||
const projectDir = path.join(testDir, 'stable-project');
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const first = detect(projectDir, homeDir).PROJECT_ID;
|
||||
const second = detect(projectDir, homeDir).PROJECT_ID;
|
||||
assert.ok(first && first !== 'global', 'first run should produce a real id');
|
||||
assert.strictEqual(second, first, 'the same non-git dir must hash to the same PROJECT_ID');
|
||||
} finally {
|
||||
cleanupDir(testDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('distinct non-git dirs produce distinct PROJECT_IDs', () => {
|
||||
const testDir = createTempDir();
|
||||
try {
|
||||
const homeDir = path.join(testDir, 'home');
|
||||
const dirA = path.join(testDir, 'project-a');
|
||||
const dirB = path.join(testDir, 'project-b');
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
fs.mkdirSync(dirA, { recursive: true });
|
||||
fs.mkdirSync(dirB, { recursive: true });
|
||||
|
||||
const idA = detect(dirA, homeDir).PROJECT_ID;
|
||||
const idB = detect(dirB, homeDir).PROJECT_ID;
|
||||
assert.ok(idA && idA !== 'global' && idB && idB !== 'global', 'both dirs should get real ids');
|
||||
assert.notStrictEqual(idA, idB, 'different non-git dirs must not share a project id');
|
||||
} finally {
|
||||
cleanupDir(testDir);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n--- Gating: bare non-git cwd stays global ---');
|
||||
|
||||
test('non-git cwd with no CLAUDE_PROJECT_DIR still falls back to global', () => {
|
||||
const testDir = createTempDir();
|
||||
try {
|
||||
const homeDir = path.join(testDir, 'home');
|
||||
const projectDir = path.join(testDir, 'unregistered');
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const vars = detect(projectDir, homeDir, { setProjectDir: false });
|
||||
assert.strictEqual(
|
||||
vars.PROJECT_ID,
|
||||
'global',
|
||||
`without CLAUDE_PROJECT_DIR an arbitrary non-git cwd must stay "global", got: "${vars.PROJECT_ID || ''}"`
|
||||
);
|
||||
} finally {
|
||||
cleanupDir(testDir);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n--- Cross-impl consistency: shell observer vs Python CLI ---');
|
||||
|
||||
// Guard the Python-dependent case OUTSIDE test() so an unmet prerequisite is
|
||||
// counted as skipped, never as a silent pass. This is not a hard failure:
|
||||
// detect-project.sh itself degrades gracefully without Python (it falls back to
|
||||
// shasum/sha256sum), so a Python-less host is a supported environment where the
|
||||
// cross-check simply cannot run.
|
||||
if (!PYTHON) {
|
||||
console.log(' ⊘ instinct-cli.py cross-impl check (skipped — no Python interpreter available)');
|
||||
skipped++;
|
||||
} else {
|
||||
test('instinct-cli.py assigns the same non-global id as detect-project.sh', () => {
|
||||
const testDir = createTempDir();
|
||||
try {
|
||||
const homeDir = path.join(testDir, 'home');
|
||||
const projectDir = path.join(testDir, 'shared-nongit-project');
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const shellId = detect(projectDir, homeDir).PROJECT_ID;
|
||||
const pyId = pythonProjectId(projectDir, homeDir);
|
||||
assert.ok(shellId && shellId !== 'global', `shell id should be real, got "${shellId}"`);
|
||||
assert.ok(pyId && pyId !== 'global', `python id should be real, got "${pyId}"`);
|
||||
assert.strictEqual(
|
||||
pyId,
|
||||
shellId,
|
||||
`observer (${shellId}) and CLI (${pyId}) must agree so observations/instincts stay grouped`
|
||||
);
|
||||
} finally {
|
||||
cleanupDir(testDir);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== Test Results ===');
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log(`Skipped: ${skipped}`);
|
||||
console.log(`Total: ${passed + failed + skipped}\n`);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user