mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-16 08:06:24 +03:00
Add IMPECCABLE_CACHE_ROOT to relocate hook state out of project roots (#422)
Honor an optional IMPECCABLE_CACHE_ROOT env var in getCachePath() / getPendingPath(): when set, hook.cache.json and hook.pending.json land under $IMPECCABLE_CACHE_ROOT/<project-slug>/ (slug = project path with [:\/.] mapped to hyphens, mirroring Claude Code's ~/.claude/projects/ convention). Unset or blank env keeps stock project-local behavior. User-authored config (config.json, config.local.json, design.json) deliberately stays project-local - only disposable state relocates. Also clears ambient IMPECCABLE_CACHE_ROOT at the top of hook.test.mjs so a developer running the suite with the redirect active still gets deterministic stock-path assertions; the new suite sets and restores the var explicitly. Prepared with AI assistance (Claude Code) under direction of 0xDarkMatter, per the maintainer-approved issue #422. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
committed by
Abdul Wahab
co-authored by
Claude Fable 5
parent
0c2517884d
commit
77a2eae861
@@ -210,12 +210,29 @@ export function getLocalConfigPath(cwd) {
|
||||
return path.join(cwd, '.impeccable', 'config.local.json');
|
||||
}
|
||||
|
||||
// Where mutable hook state (cache + pending) lives. Defaults to the
|
||||
// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state
|
||||
// relocates to a per-project subdirectory of that root instead, keyed by a
|
||||
// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's
|
||||
// `~/.claude/projects/` convention), so project roots stay free of tool
|
||||
// artifacts (issue #422). User-authored config (config.json,
|
||||
// config.local.json, design.json) deliberately stays project-local — only
|
||||
// disposable state relocates.
|
||||
function hookStateDir(cwd) {
|
||||
const root = process.env.IMPECCABLE_CACHE_ROOT;
|
||||
if (root && typeof root === 'string' && root.trim()) {
|
||||
const slug = String(cwd).replace(/[:\\/.]/g, '-');
|
||||
return path.join(root, slug);
|
||||
}
|
||||
return path.join(cwd, '.impeccable');
|
||||
}
|
||||
|
||||
export function getCachePath(cwd) {
|
||||
return path.join(cwd, '.impeccable', 'hook.cache.json');
|
||||
return path.join(hookStateDir(cwd), 'hook.cache.json');
|
||||
}
|
||||
|
||||
export function getPendingPath(cwd) {
|
||||
return path.join(cwd, '.impeccable', 'hook.pending.json');
|
||||
return path.join(hookStateDir(cwd), 'hook.pending.json');
|
||||
}
|
||||
|
||||
export function resolveProjectCwd(event, fallback = process.cwd()) {
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
truthy,
|
||||
getConfigPath,
|
||||
getLocalConfigPath,
|
||||
getCachePath,
|
||||
getPendingPath,
|
||||
ensureHookGitExcludes,
|
||||
readConfig,
|
||||
readCache,
|
||||
@@ -67,6 +69,12 @@ import {
|
||||
import { normalizeIgnoreValueEntries as normalizeIgnoreValueEntriesCli } from '../cli/lib/impeccable-config.mjs';
|
||||
import { detectHtml, detectText } from '../cli/engine/detect-antipatterns.mjs';
|
||||
|
||||
// Hook state paths are env-sensitive: an ambient IMPECCABLE_CACHE_ROOT (a
|
||||
// developer using the redirect locally) would relocate cache/pending out of
|
||||
// the tmp projects and break stock-path assertions. Clear it up front; the
|
||||
// dedicated issue-#422 suite sets and restores it explicitly.
|
||||
delete process.env.IMPECCABLE_CACHE_ROOT;
|
||||
|
||||
function mkTmp() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-hook-'));
|
||||
}
|
||||
@@ -415,6 +423,68 @@ describe('readCache / persistCache / bumpEditCount', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('IMPECCABLE_CACHE_ROOT relocates hook state (issue #422)', () => {
|
||||
let cwd;
|
||||
let cacheRoot;
|
||||
let savedEnv;
|
||||
beforeEach(() => {
|
||||
cwd = mkTmp();
|
||||
cacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-cache-root-'));
|
||||
savedEnv = process.env.IMPECCABLE_CACHE_ROOT;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (savedEnv === undefined) delete process.env.IMPECCABLE_CACHE_ROOT;
|
||||
else process.env.IMPECCABLE_CACHE_ROOT = savedEnv;
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('keeps hook state project-local when the env var is unset', () => {
|
||||
delete process.env.IMPECCABLE_CACHE_ROOT;
|
||||
assert.equal(getCachePath(cwd), path.join(cwd, '.impeccable', 'hook.cache.json'));
|
||||
assert.equal(getPendingPath(cwd), path.join(cwd, '.impeccable', 'hook.pending.json'));
|
||||
});
|
||||
|
||||
it('treats a blank env var as unset', () => {
|
||||
process.env.IMPECCABLE_CACHE_ROOT = ' ';
|
||||
assert.equal(getCachePath(cwd), path.join(cwd, '.impeccable', 'hook.cache.json'));
|
||||
});
|
||||
|
||||
it('relocates cache and pending under a per-project slug dir', () => {
|
||||
process.env.IMPECCABLE_CACHE_ROOT = cacheRoot;
|
||||
const slug = String(cwd).replace(/[:\\/.]/g, '-');
|
||||
assert.equal(getCachePath(cwd), path.join(cacheRoot, slug, 'hook.cache.json'));
|
||||
assert.equal(getPendingPath(cwd), path.join(cacheRoot, slug, 'hook.pending.json'));
|
||||
});
|
||||
|
||||
it('slug maps colons, slashes, backslashes, and dots to hyphens', () => {
|
||||
process.env.IMPECCABLE_CACHE_ROOT = cacheRoot;
|
||||
const cachePath = getCachePath('C:\\work\\my.app/sub');
|
||||
const slugDir = path.basename(path.dirname(cachePath));
|
||||
assert.equal(slugDir, 'C--work-my-app-sub');
|
||||
});
|
||||
|
||||
it('config paths stay project-local even when the redirect is active', () => {
|
||||
process.env.IMPECCABLE_CACHE_ROOT = cacheRoot;
|
||||
assert.equal(getConfigPath(cwd), path.join(cwd, '.impeccable', 'config.json'));
|
||||
assert.equal(getLocalConfigPath(cwd), path.join(cwd, '.impeccable', 'config.local.json'));
|
||||
});
|
||||
|
||||
it('persistCache round-trips through the redirect dir and leaves the project root clean', () => {
|
||||
process.env.IMPECCABLE_CACHE_ROOT = cacheRoot;
|
||||
const cache = readCache(cwd);
|
||||
bumpEditCount(cache, 'sid-1', '/x/a.tsx');
|
||||
assert.equal(persistCache(cwd, cache), true);
|
||||
|
||||
assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false, 'project root untouched');
|
||||
const slug = String(cwd).replace(/[:\\/.]/g, '-');
|
||||
assert.equal(fs.existsSync(path.join(cacheRoot, slug, 'hook.cache.json')), true);
|
||||
|
||||
const reloaded = readCache(cwd);
|
||||
assert.equal(reloaded.sessions['sid-1'].files['/x/a.tsx'].editCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureHookGitExcludes()', () => {
|
||||
let cwd;
|
||||
beforeEach(() => { cwd = mkTmp(); });
|
||||
|
||||
Reference in New Issue
Block a user