mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-30 01:51:20 +00:00
Merge branch 'main' into rios/fix-ruby-factory-block-owner
This commit is contained in:
commit
bbd9ec9ce2
47 changed files with 5694 additions and 121 deletions
10
AGENTS.md
10
AGENTS.md
|
|
@ -1,7 +1,7 @@
|
|||
<!-- version: 1.15.0 -->
|
||||
<!-- Last updated: 2026-09-07 -->
|
||||
<!-- version: 1.17.0 -->
|
||||
<!-- Last updated: 2026-09-24 -->
|
||||
|
||||
Last reviewed: 2026-09-07
|
||||
Last reviewed: 2026-09-24
|
||||
|
||||
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
|
||||
|
||||
|
|
@ -91,6 +91,8 @@ mirror. `gitnexus/test/unit/shipped-skills-sync.test.ts` guards the copies. Toke
|
|||
|
||||
| Date | Version | Change |
|
||||
|------|---------|--------|
|
||||
| 2026-09-24 | 1.17.0 | Clones with the same `origin` URL now share a store automatically; `--no-share` records a lasting opt-out (#3352). |
|
||||
| 2026-09-24 | 1.16.0 | Documented the shared worktree index store (`<GITNEXUS_HOME>/stores/`, `analyze --share-with`, `GITNEXUS_SHARED_STORE=off`) in the storage notes (#3352). |
|
||||
| 2026-09-07 | 1.15.0 | Added the Objective-C provider guide as the required reference before changing Objective-C parsing or resolution. |
|
||||
| 2026-07-20 | 1.14.0 | `gitnexus-review` gains a coordinated swarm: six `ci-personas/` lanes the CI review agent dispatches as subagents (via the `Agent` tool), with a bounded critic gate and sidechain-excluded evidence. |
|
||||
| 2026-07-16 | 1.13.0 | `gitnexus-plan` asks plan depth up front (quick/standard/deep) in interactive runs; `gitnexus-lfg` gate slimmed to proceed/stop (Deepen stays as the route-back mechanism). |
|
||||
|
|
@ -198,4 +200,4 @@ npx gitnexus serve # HTTP API on port 4747 (from any ind
|
|||
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (`build-tree-sitter-grammars.cjs` activates committed prebuilds in place under `vendor/`, and only source-builds when none matches). A C/C++ toolchain (`python3`, `make`, `g++`) is needed only for that source-build fallback.
|
||||
- The vendored grammars `tree-sitter-{c,dart,proto,swift,kotlin,zig}` are handled uniformly: c is required; dart/proto/swift/kotlin/zig are optional and skippable via `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1`. Install warnings appear only when no prebuild matches the platform-arch and no toolchain is present, and are non-fatal — only that language's parsing is unavailable.
|
||||
- ESLint configured via `eslint.config.mjs` (TS, React Hooks, unused-imports). No `npm run lint` script; use `npx eslint .`. Prettier runs via lint-staged. CI checks both in `ci-quality.yml`.
|
||||
- Index storage defaults to `<repo>/.gitnexus/`. `GITNEXUS_STORAGE_PATH` selects one complete external index directory and wins over `GITNEXUS_STORAGE_ROOT`, which creates an isolated `<repo-basename>-<12-hex>/` slot per repository. `GITNEXUS_CONTENT_RETENTION` is `full` (default), `symbol`, or `none`. MCP `list_repos`, `gitnexus://repo/{name}/context`, and HTTP `GET /api/repos` / `GET /api/repo` expose `storagePath`, `contentRetention`, and `sourceAvailable`. HTTP `/api/file` and `/api/grep` return 410 unless retention is `full`; MCP `include_content` may still return symbol spans at `symbol`.
|
||||
- Index storage defaults to `<repo>/.gitnexus/`. `GITNEXUS_STORAGE_PATH` selects one complete external index directory and wins over `GITNEXUS_STORAGE_ROOT`, which creates an isolated `<repo-basename>-<12-hex>/` slot per repository. Linked worktrees share one store under `<GITNEXUS_HOME>/stores/<key>/` (one immutable graph per commit, private graphs for checkouts with local changes, shared parse caches); clones with the same `origin` URL join a registered sibling's store automatically (`analyze --share-with` names one, `--no-share` opts out and is remembered), and `GITNEXUS_SHARED_STORE=off` or either storage env var disables sharing (#3352). `GITNEXUS_CONTENT_RETENTION` is `full` (default), `symbol`, or `none`. MCP `list_repos`, `gitnexus://repo/{name}/context`, and HTTP `GET /api/repos` / `GET /api/repo` expose `storagePath`, `contentRetention`, and `sourceAvailable`. HTTP `/api/file` and `/api/grep` return 410 unless retention is `full`; MCP `include_content` may still return symbol spans at `symbol`.
|
||||
|
|
|
|||
|
|
@ -491,9 +491,22 @@ CLI (analyze.ts) → runFullAnalysis(repoPath, options, callbacks)
|
|||
└── meta.json # legacy mirror of gitnexus.json, kept in sync (see MIGRATION.md)
|
||||
|
||||
~/.gitnexus/
|
||||
└── registry.json # Global repo registry (MCP discovery)
|
||||
├── registry.json # Global repo registry (MCP discovery)
|
||||
└── stores/<key>/ # Shared sibling index store (see below)
|
||||
├── caches/ # parse cache + durable ParsedFile store
|
||||
├── commits/<commit>-<featureKey>/ # one immutable graph per commit + settings
|
||||
└── checkouts/<slot>/ # one checkout's metadata, membership, and
|
||||
# private graph when it has local edits
|
||||
```
|
||||
|
||||
The flat `<repo>/.gitnexus/` layout applies to a standalone repository and
|
||||
whenever `GITNEXUS_STORAGE_PATH` / `GITNEXUS_STORAGE_ROOT` is set. A repository
|
||||
with linked worktrees, and clones with the same `origin` URL, share one
|
||||
`stores/<key>/` automatically (a clone opts out with `analyze --no-share`;
|
||||
`GITNEXUS_SHARED_STORE=off` turns sharing off entirely). Each sharing checkout
|
||||
keeps only a `.gitnexus/store.json` pointer to its store. Path resolution lives
|
||||
in `shared-store.ts`.
|
||||
|
||||
Read-only opens self-heal an interrupted checkpoint: the refusal is
|
||||
classified and cleared by one writable open (probe + `CHECKPOINT`) before
|
||||
the read-only open is retried — see `sidecar-recovery.ts`
|
||||
|
|
|
|||
|
|
@ -628,6 +628,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
|
|||
| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. |
|
||||
| `GITNEXUS_STORAGE_PATH` | unset (`<repo>/.gitnexus/`) | Complete external index directory. This preserves the existing configuration semantics and takes precedence over `GITNEXUS_STORAGE_ROOT` when both are set. | You already keep one repository index outside its checkout or need one explicit index location. |
|
||||
| `GITNEXUS_STORAGE_ROOT` | unset | Absolute root directory for external indexes. GitNexus creates an isolated `<repo-basename>-<canonical-path-hash>/` slot beneath it for each repository, then registers the resolved slot so `status`, MCP, and `serve` can reopen it later. | You want to manage multiple repository indexes centrally or keep generated data outside source checkouts. |
|
||||
| `GITNEXUS_SHARED_STORE` | unset (on) | Set to `off` (or `0`, `false`, `no`) to turn off shared index stores for both linked git worktrees and sibling clones; every checkout then indexes into its own `.gitnexus/`. Sharing is also off whenever `GITNEXUS_STORAGE_PATH` or `GITNEXUS_STORAGE_ROOT` is set. | Disk or memory is not a concern, or you want each worktree's index fully independent. |
|
||||
| `GITNEXUS_CONTENT_RETENTION` | `full` | Source-text retention profile: `full` keeps file and symbol text, `symbol` keeps symbol snippets without full file content, and `none` keeps the structural graph without source body text. | You need to reduce persisted source text while preserving graph structure. |
|
||||
| `GITNEXUS_SKIP_FTS` | unset | When exactly `1`, skips FTS extension loading and keyword index creation during analyze. Equivalent to `--skip-fts`; a later analyze without either option restores FTS. | Graph-only consumers with their own retrieval, or short-lived indexes that do not need keyword search. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
|
|
@ -709,6 +710,8 @@ GitNexus uses a **global registry** so one MCP server can serve multiple indexed
|
|||
|
||||
Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo by default (portable, gitignored). `GITNEXUS_STORAGE_PATH` selects one complete external index directory and preserves the established configuration behavior. To manage multiple repositories under one external directory, set `GITNEXUS_STORAGE_ROOT`; GitNexus derives an isolated `<repo-basename>-<canonical-path-hash>/` slot beneath it for each repository. If both variables are set, `GITNEXUS_STORAGE_PATH` takes precedence. GitNexus registers the resolved slot in `~/.gitnexus/registry.json`, allowing later `status`, MCP, and `serve` commands to reopen the index without repeating the environment variable. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). Read-only tools can omit `repo` when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Outside those paths—and for mutating tools with multiple indexed repos and no MCP default—pass `repo` explicitly.
|
||||
|
||||
**Worktrees share one index store.** When a repository has linked worktrees (`git worktree add`), the main checkout and every worktree index into one store at `~/.gitnexus/stores/<repo>/` instead of each keeping a full `.gitnexus/`. Checkouts at the same commit with no local changes read one shared, read-only graph: one copy on disk and one open database in MCP. A checkout with uncommitted changes gets its own graph, copied from the nearest shared graph and updated incrementally rather than rebuilt. Parse caches are shared too. Each worktree keeps a small `.gitnexus/store.json` pointer, and an index it had before sharing is left in place; `gitnexus status` reports it and `gitnexus clean --local-index --force` removes it. `gitnexus clean` in one worktree removes only that worktree's slot and any shared graph no other checkout uses; `gitnexus clean --gc` also drops slots whose worktree was deleted. Independent clones of one repository share too: when another registered clone has the same `origin` URL, `gitnexus analyze` in a clone joins that clone's store (or starts one the other clone joins on its next analyze). A lone clone keeps its own `.gitnexus/`. `gitnexus analyze --share-with <name-or-path>` joins a specific checkout's store after checking the `origin` URLs match, and `--no-share` moves a clone back to its own `.gitnexus/` and keeps it out until `--share-with`. On filesystems with copy-on-write clones (APFS, btrfs, XFS) a checkout's private graph shares its unchanged pages with the shared graph on disk; elsewhere it is a full copy, and `gitnexus status` says which. Queries cannot combine two graphs, because LadybugDB reads one database per query, so a checkout with edits always has a complete graph of its own. Set `GITNEXUS_SHARED_STORE=off` (or `0`, `false`, `no`) to turn sharing off for worktrees and clones alike.
|
||||
|
||||
<details>
|
||||
<summary><strong>Architecture diagram</strong></summary>
|
||||
|
||||
|
|
|
|||
|
|
@ -218,11 +218,11 @@ function branchSlug(rawRef) {
|
|||
return `${safe}-${hash}`;
|
||||
}
|
||||
|
||||
// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly
|
||||
// Mirror gitnexus/src/storage/storage-slot.ts slotNameForCanonicalPath exactly
|
||||
// (sanitize + sha256 of the canonical repo path, 12-hex suffix).
|
||||
function sanitizeSlotBasename(value) {
|
||||
// Cap first, then walk the tail once — same order as
|
||||
// gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS).
|
||||
// gitnexus/src/storage/storage-slot.ts (avoids /[. ]+$/ ReDoS).
|
||||
const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80);
|
||||
let end = sanitized.length;
|
||||
while (end > 0) {
|
||||
|
|
@ -231,9 +231,13 @@ function sanitizeSlotBasename(value) {
|
|||
end--;
|
||||
}
|
||||
const candidate = sanitized.slice(0, end) || 'repository';
|
||||
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate)
|
||||
? `repository-${candidate}`
|
||||
: candidate;
|
||||
// Windows also reserves device names with an extension (`CON.txt`); same
|
||||
// platform branch as gitnexus/src/storage/storage-slot.ts.
|
||||
const reserved =
|
||||
process.platform === 'win32'
|
||||
? /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
|
||||
: /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
||||
return reserved.test(candidate) ? `repository-${candidate}` : candidate;
|
||||
}
|
||||
|
||||
function storageSlotName(repoPath) {
|
||||
|
|
@ -293,6 +297,37 @@ function resolveEntryStoragePath(entry) {
|
|||
return path.resolve(path.join(entry.path, GITNEXUS_DIR));
|
||||
}
|
||||
|
||||
// A single path segment: `..repo-<hash>` is a legal slot name, `..` is not.
|
||||
function isDirectChild(parent, child) {
|
||||
const rel = path.relative(parent, child);
|
||||
return rel !== '' && rel !== '..' && !path.isAbsolute(rel) && !rel.includes(path.sep);
|
||||
}
|
||||
|
||||
// Mirror gitnexus/src/storage/shared-store.ts resolveGraphPath (#3352): a
|
||||
// shared-store checkout slot may read a commit graph in the same store
|
||||
// instead of owning <slot>/lbug. Any other recorded value is ignored.
|
||||
function resolveGraphPath(storagePath, metadata) {
|
||||
const own = path.join(storagePath, LBUG_DIRECTORY);
|
||||
const storesRoot = path.resolve(
|
||||
process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'),
|
||||
'stores',
|
||||
);
|
||||
const slot = path.resolve(storagePath);
|
||||
const checkoutsDir = path.dirname(slot);
|
||||
const root = path.dirname(checkoutsDir);
|
||||
if (path.basename(checkoutsDir) !== 'checkouts') return own;
|
||||
if (!isDirectChild(checkoutsDir, slot) || !isDirectChild(storesRoot, root)) return own;
|
||||
const recorded = metadata && metadata.graphPath;
|
||||
if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own;
|
||||
const graph = path.resolve(recorded);
|
||||
// Only a published `<commit>-<featureKey>` dir, never `.publish-*` staging.
|
||||
const valid =
|
||||
path.basename(graph) === LBUG_DIRECTORY &&
|
||||
isDirectChild(path.join(root, 'commits'), path.dirname(graph)) &&
|
||||
/^[0-9a-f]{7,64}-[0-9a-f]{8,64}$/.test(path.basename(path.dirname(graph)));
|
||||
return valid ? graph : own;
|
||||
}
|
||||
|
||||
function hasLocalIndexSignal(storagePath) {
|
||||
try {
|
||||
return (
|
||||
|
|
@ -382,7 +417,9 @@ function findRegisteredRepo(cwd) {
|
|||
best = {
|
||||
path: entry.path,
|
||||
storagePath,
|
||||
lbugPath: path.join(indexDir, LBUG_DIRECTORY),
|
||||
lbugPath: branchIsIndexed
|
||||
? path.join(indexDir, LBUG_DIRECTORY)
|
||||
: resolveGraphPath(storagePath, ownershipMetadata),
|
||||
metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,11 +218,11 @@ function branchSlug(rawRef) {
|
|||
return `${safe}-${hash}`;
|
||||
}
|
||||
|
||||
// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly
|
||||
// Mirror gitnexus/src/storage/storage-slot.ts slotNameForCanonicalPath exactly
|
||||
// (sanitize + sha256 of the canonical repo path, 12-hex suffix).
|
||||
function sanitizeSlotBasename(value) {
|
||||
// Cap first, then walk the tail once — same order as
|
||||
// gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS).
|
||||
// gitnexus/src/storage/storage-slot.ts (avoids /[. ]+$/ ReDoS).
|
||||
const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80);
|
||||
let end = sanitized.length;
|
||||
while (end > 0) {
|
||||
|
|
@ -231,9 +231,13 @@ function sanitizeSlotBasename(value) {
|
|||
end--;
|
||||
}
|
||||
const candidate = sanitized.slice(0, end) || 'repository';
|
||||
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate)
|
||||
? `repository-${candidate}`
|
||||
: candidate;
|
||||
// Windows also reserves device names with an extension (`CON.txt`); same
|
||||
// platform branch as gitnexus/src/storage/storage-slot.ts.
|
||||
const reserved =
|
||||
process.platform === 'win32'
|
||||
? /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
|
||||
: /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
||||
return reserved.test(candidate) ? `repository-${candidate}` : candidate;
|
||||
}
|
||||
|
||||
function storageSlotName(repoPath) {
|
||||
|
|
@ -293,6 +297,37 @@ function resolveEntryStoragePath(entry) {
|
|||
return path.resolve(path.join(entry.path, GITNEXUS_DIR));
|
||||
}
|
||||
|
||||
// A single path segment: `..repo-<hash>` is a legal slot name, `..` is not.
|
||||
function isDirectChild(parent, child) {
|
||||
const rel = path.relative(parent, child);
|
||||
return rel !== '' && rel !== '..' && !path.isAbsolute(rel) && !rel.includes(path.sep);
|
||||
}
|
||||
|
||||
// Mirror gitnexus/src/storage/shared-store.ts resolveGraphPath (#3352): a
|
||||
// shared-store checkout slot may read a commit graph in the same store
|
||||
// instead of owning <slot>/lbug. Any other recorded value is ignored.
|
||||
function resolveGraphPath(storagePath, metadata) {
|
||||
const own = path.join(storagePath, LBUG_DIRECTORY);
|
||||
const storesRoot = path.resolve(
|
||||
process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'),
|
||||
'stores',
|
||||
);
|
||||
const slot = path.resolve(storagePath);
|
||||
const checkoutsDir = path.dirname(slot);
|
||||
const root = path.dirname(checkoutsDir);
|
||||
if (path.basename(checkoutsDir) !== 'checkouts') return own;
|
||||
if (!isDirectChild(checkoutsDir, slot) || !isDirectChild(storesRoot, root)) return own;
|
||||
const recorded = metadata && metadata.graphPath;
|
||||
if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own;
|
||||
const graph = path.resolve(recorded);
|
||||
// Only a published `<commit>-<featureKey>` dir, never `.publish-*` staging.
|
||||
const valid =
|
||||
path.basename(graph) === LBUG_DIRECTORY &&
|
||||
isDirectChild(path.join(root, 'commits'), path.dirname(graph)) &&
|
||||
/^[0-9a-f]{7,64}-[0-9a-f]{8,64}$/.test(path.basename(path.dirname(graph)));
|
||||
return valid ? graph : own;
|
||||
}
|
||||
|
||||
function hasLocalIndexSignal(storagePath) {
|
||||
try {
|
||||
return (
|
||||
|
|
@ -382,7 +417,9 @@ function findRegisteredRepo(cwd) {
|
|||
best = {
|
||||
path: entry.path,
|
||||
storagePath,
|
||||
lbugPath: path.join(indexDir, LBUG_DIRECTORY),
|
||||
lbugPath: branchIsIndexed
|
||||
? path.join(indexDir, LBUG_DIRECTORY)
|
||||
: resolveGraphPath(storagePath, ownershipMetadata),
|
||||
metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,11 +218,11 @@ function branchSlug(rawRef) {
|
|||
return `${safe}-${hash}`;
|
||||
}
|
||||
|
||||
// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly
|
||||
// Mirror gitnexus/src/storage/storage-slot.ts slotNameForCanonicalPath exactly
|
||||
// (sanitize + sha256 of the canonical repo path, 12-hex suffix).
|
||||
function sanitizeSlotBasename(value) {
|
||||
// Cap first, then walk the tail once — same order as
|
||||
// gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS).
|
||||
// gitnexus/src/storage/storage-slot.ts (avoids /[. ]+$/ ReDoS).
|
||||
const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80);
|
||||
let end = sanitized.length;
|
||||
while (end > 0) {
|
||||
|
|
@ -231,9 +231,13 @@ function sanitizeSlotBasename(value) {
|
|||
end--;
|
||||
}
|
||||
const candidate = sanitized.slice(0, end) || 'repository';
|
||||
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate)
|
||||
? `repository-${candidate}`
|
||||
: candidate;
|
||||
// Windows also reserves device names with an extension (`CON.txt`); same
|
||||
// platform branch as gitnexus/src/storage/storage-slot.ts.
|
||||
const reserved =
|
||||
process.platform === 'win32'
|
||||
? /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
|
||||
: /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
||||
return reserved.test(candidate) ? `repository-${candidate}` : candidate;
|
||||
}
|
||||
|
||||
function storageSlotName(repoPath) {
|
||||
|
|
@ -293,6 +297,37 @@ function resolveEntryStoragePath(entry) {
|
|||
return path.resolve(path.join(entry.path, GITNEXUS_DIR));
|
||||
}
|
||||
|
||||
// A single path segment: `..repo-<hash>` is a legal slot name, `..` is not.
|
||||
function isDirectChild(parent, child) {
|
||||
const rel = path.relative(parent, child);
|
||||
return rel !== '' && rel !== '..' && !path.isAbsolute(rel) && !rel.includes(path.sep);
|
||||
}
|
||||
|
||||
// Mirror gitnexus/src/storage/shared-store.ts resolveGraphPath (#3352): a
|
||||
// shared-store checkout slot may read a commit graph in the same store
|
||||
// instead of owning <slot>/lbug. Any other recorded value is ignored.
|
||||
function resolveGraphPath(storagePath, metadata) {
|
||||
const own = path.join(storagePath, LBUG_DIRECTORY);
|
||||
const storesRoot = path.resolve(
|
||||
process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'),
|
||||
'stores',
|
||||
);
|
||||
const slot = path.resolve(storagePath);
|
||||
const checkoutsDir = path.dirname(slot);
|
||||
const root = path.dirname(checkoutsDir);
|
||||
if (path.basename(checkoutsDir) !== 'checkouts') return own;
|
||||
if (!isDirectChild(checkoutsDir, slot) || !isDirectChild(storesRoot, root)) return own;
|
||||
const recorded = metadata && metadata.graphPath;
|
||||
if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own;
|
||||
const graph = path.resolve(recorded);
|
||||
// Only a published `<commit>-<featureKey>` dir, never `.publish-*` staging.
|
||||
const valid =
|
||||
path.basename(graph) === LBUG_DIRECTORY &&
|
||||
isDirectChild(path.join(root, 'commits'), path.dirname(graph)) &&
|
||||
/^[0-9a-f]{7,64}-[0-9a-f]{8,64}$/.test(path.basename(path.dirname(graph)));
|
||||
return valid ? graph : own;
|
||||
}
|
||||
|
||||
function hasLocalIndexSignal(storagePath) {
|
||||
try {
|
||||
return (
|
||||
|
|
@ -382,7 +417,9 @@ function findRegisteredRepo(cwd) {
|
|||
best = {
|
||||
path: entry.path,
|
||||
storagePath,
|
||||
lbugPath: path.join(indexDir, LBUG_DIRECTORY),
|
||||
lbugPath: branchIsIndexed
|
||||
? path.join(indexDir, LBUG_DIRECTORY)
|
||||
: resolveGraphPath(storagePath, ownershipMetadata),
|
||||
metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,11 +218,11 @@ function branchSlug(rawRef) {
|
|||
return `${safe}-${hash}`;
|
||||
}
|
||||
|
||||
// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly
|
||||
// Mirror gitnexus/src/storage/storage-slot.ts slotNameForCanonicalPath exactly
|
||||
// (sanitize + sha256 of the canonical repo path, 12-hex suffix).
|
||||
function sanitizeSlotBasename(value) {
|
||||
// Cap first, then walk the tail once — same order as
|
||||
// gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS).
|
||||
// gitnexus/src/storage/storage-slot.ts (avoids /[. ]+$/ ReDoS).
|
||||
const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80);
|
||||
let end = sanitized.length;
|
||||
while (end > 0) {
|
||||
|
|
@ -231,9 +231,13 @@ function sanitizeSlotBasename(value) {
|
|||
end--;
|
||||
}
|
||||
const candidate = sanitized.slice(0, end) || 'repository';
|
||||
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate)
|
||||
? `repository-${candidate}`
|
||||
: candidate;
|
||||
// Windows also reserves device names with an extension (`CON.txt`); same
|
||||
// platform branch as gitnexus/src/storage/storage-slot.ts.
|
||||
const reserved =
|
||||
process.platform === 'win32'
|
||||
? /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
|
||||
: /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
||||
return reserved.test(candidate) ? `repository-${candidate}` : candidate;
|
||||
}
|
||||
|
||||
function storageSlotName(repoPath) {
|
||||
|
|
@ -293,6 +297,37 @@ function resolveEntryStoragePath(entry) {
|
|||
return path.resolve(path.join(entry.path, GITNEXUS_DIR));
|
||||
}
|
||||
|
||||
// A single path segment: `..repo-<hash>` is a legal slot name, `..` is not.
|
||||
function isDirectChild(parent, child) {
|
||||
const rel = path.relative(parent, child);
|
||||
return rel !== '' && rel !== '..' && !path.isAbsolute(rel) && !rel.includes(path.sep);
|
||||
}
|
||||
|
||||
// Mirror gitnexus/src/storage/shared-store.ts resolveGraphPath (#3352): a
|
||||
// shared-store checkout slot may read a commit graph in the same store
|
||||
// instead of owning <slot>/lbug. Any other recorded value is ignored.
|
||||
function resolveGraphPath(storagePath, metadata) {
|
||||
const own = path.join(storagePath, LBUG_DIRECTORY);
|
||||
const storesRoot = path.resolve(
|
||||
process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'),
|
||||
'stores',
|
||||
);
|
||||
const slot = path.resolve(storagePath);
|
||||
const checkoutsDir = path.dirname(slot);
|
||||
const root = path.dirname(checkoutsDir);
|
||||
if (path.basename(checkoutsDir) !== 'checkouts') return own;
|
||||
if (!isDirectChild(checkoutsDir, slot) || !isDirectChild(storesRoot, root)) return own;
|
||||
const recorded = metadata && metadata.graphPath;
|
||||
if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own;
|
||||
const graph = path.resolve(recorded);
|
||||
// Only a published `<commit>-<featureKey>` dir, never `.publish-*` staging.
|
||||
const valid =
|
||||
path.basename(graph) === LBUG_DIRECTORY &&
|
||||
isDirectChild(path.join(root, 'commits'), path.dirname(graph)) &&
|
||||
/^[0-9a-f]{7,64}-[0-9a-f]{8,64}$/.test(path.basename(path.dirname(graph)));
|
||||
return valid ? graph : own;
|
||||
}
|
||||
|
||||
function hasLocalIndexSignal(storagePath) {
|
||||
try {
|
||||
return (
|
||||
|
|
@ -382,7 +417,9 @@ function findRegisteredRepo(cwd) {
|
|||
best = {
|
||||
path: entry.path,
|
||||
storagePath,
|
||||
lbugPath: path.join(indexDir, LBUG_DIRECTORY),
|
||||
lbugPath: branchIsIndexed
|
||||
? path.join(indexDir, LBUG_DIRECTORY)
|
||||
: resolveGraphPath(storagePath, ownershipMetadata),
|
||||
metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
* CLAUDE.md is for Claude Code which only reads that file.
|
||||
*/
|
||||
|
||||
import { GITNEXUS_DIR } from '../storage/storage-constants.js';
|
||||
import { storeRootOfCheckoutSlot } from '../storage/shared-store.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
|
@ -619,7 +621,13 @@ export async function generateAIContextFiles(
|
|||
// CLI and hooks already share; failure to copy is non-fatal (docs carry a
|
||||
// bootstrap fallback). `runnerPath` is project-relative with POSIX separators
|
||||
// so the emitted command is identical across platforms.
|
||||
const runnerPath = path.relative(repoPath, path.join(storagePath, 'run.cjs')).replace(/\\/g, '/');
|
||||
// A shared-store slot (#3352) lives under the GitNexus home, so its path
|
||||
// would be machine- and worktree-specific in committed docs; the runner goes
|
||||
// next to the checkout's store pointer instead.
|
||||
const runnerDir = storeRootOfCheckoutSlot(storagePath)
|
||||
? path.join(repoPath, GITNEXUS_DIR)
|
||||
: storagePath;
|
||||
const runnerPath = path.relative(repoPath, path.join(runnerDir, 'run.cjs')).replace(/\\/g, '/');
|
||||
try {
|
||||
const runnerSrc = path.join(
|
||||
__dirname,
|
||||
|
|
@ -629,8 +637,8 @@ export async function generateAIContextFiles(
|
|||
'claude',
|
||||
'resolve-analyze-cmd.cjs',
|
||||
);
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
await fs.copyFile(runnerSrc, path.join(storagePath, 'run.cjs'));
|
||||
await fs.mkdir(runnerDir, { recursive: true });
|
||||
await fs.copyFile(runnerSrc, path.join(runnerDir, 'run.cjs'));
|
||||
} catch (err) {
|
||||
logger.warn(`Could not write GitNexus runner to ${runnerPath}: ${String(err)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,6 +103,10 @@ export interface AnalyzeOptions {
|
|||
* `allowDuplicateName` option end-to-end.
|
||||
*/
|
||||
allowDuplicateName?: boolean;
|
||||
/** `--share-with <repo>`: join that checkout's shared store (#3352). */
|
||||
shareWith?: string;
|
||||
/** `--no-share` sets this to false: leave the shared store (#3352). */
|
||||
share?: boolean;
|
||||
/**
|
||||
* Override the walker's large-file skip threshold (#991). Value in KB;
|
||||
* clamped downstream to the tree-sitter 32 MB ceiling. Sets
|
||||
|
|
|
|||
|
|
@ -1396,6 +1396,8 @@ const analyzeCommandImpl = async (
|
|||
// be able to accept the duplicate name without also paying the
|
||||
// cost of a full pipeline re-index. See #829 review round 2.
|
||||
allowDuplicateName: options.allowDuplicateName,
|
||||
shareWith: options.shareWith,
|
||||
noShare: options.share === false,
|
||||
// Worker pool size threaded from --workers, replacing the previous
|
||||
// GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the
|
||||
// env / auto-formula fallback inside the pipeline.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
type RegistryEntry,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { requireDeletableStoragePath, StorageDeletionError } from '../storage/storage-resolver.js';
|
||||
import { formatStaleSlotLine } from './stale-branch-format.js';
|
||||
import { formatSlotSize, formatStaleSlotLine } from './stale-branch-format.js';
|
||||
import { listLocalHeads } from '../storage/git.js';
|
||||
import {
|
||||
isContainedBranchDir,
|
||||
|
|
@ -33,6 +33,16 @@ import {
|
|||
listParkedLbugSidecars,
|
||||
} from '../core/lbug/sidecar-recovery.js';
|
||||
import { t } from './i18n/index.js';
|
||||
import { getGlobalDir } from '../storage/global-dir.js';
|
||||
import { STORES_DIR } from '../storage/shared-store.js';
|
||||
import {
|
||||
findLegacyLocalIndex,
|
||||
reclaimAfterSlotRemoval,
|
||||
reclaimSharedStore,
|
||||
removeLegacyLocalIndex,
|
||||
removeCheckoutStorage,
|
||||
type ReclaimResult,
|
||||
} from '../storage/shared-store-lifecycle.js';
|
||||
|
||||
type OwnedCwdStorage = {
|
||||
repo: NonNullable<Awaited<ReturnType<typeof findRepo>>>;
|
||||
|
|
@ -147,13 +157,101 @@ const cleanStaleBranchSlots = async (force: boolean): Promise<void> => {
|
|||
}
|
||||
};
|
||||
|
||||
const reportReclaim = (result: ReclaimResult | null): void => {
|
||||
if (!result) return;
|
||||
if (result.removed.length > 0) {
|
||||
console.log(t('clean.shared.reclaimed', { count: result.removed.length }));
|
||||
}
|
||||
if (result.kept.length > 0) console.log(t('clean.shared.kept', { count: result.kept.length }));
|
||||
};
|
||||
|
||||
/** `clean --gc`: collect every shared store under GITNEXUS_HOME (#3352). */
|
||||
const collectSharedStores = async (force: boolean): Promise<void> => {
|
||||
const storesDir = path.join(getGlobalDir(), STORES_DIR);
|
||||
// Only a missing stores root means "nothing to collect"; an unreadable one
|
||||
// must fail loudly rather than report success.
|
||||
const names = await fs.readdir(storesDir).catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'ENOENT') return [] as string[];
|
||||
throw err;
|
||||
});
|
||||
// Stray files (`.DS_Store`) are not stores, and a symlink is not followed:
|
||||
// reclaim deletes under the root it is given. A store another collector
|
||||
// removed meanwhile is simply gone. The lstat skips a stray link; it is not
|
||||
// a race guard, since stores/ belongs to the user running clean and anyone
|
||||
// able to swap an entry there can already delete the store itself.
|
||||
const roots: string[] = [];
|
||||
for (const name of names) {
|
||||
const root = path.join(storesDir, name);
|
||||
const stat = await fs.lstat(root).catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'ENOENT') return null;
|
||||
throw err;
|
||||
});
|
||||
if (stat?.isDirectory()) roots.push(root);
|
||||
}
|
||||
if (roots.length === 0) {
|
||||
console.log(t('clean.gc.none'));
|
||||
return;
|
||||
}
|
||||
for (const root of roots) {
|
||||
// Without --force this is a preview: same selection, nothing deleted.
|
||||
const result = await reclaimSharedStore(root, { gc: true, dryRun: !force });
|
||||
console.log(
|
||||
t(force ? 'clean.gc.store' : 'clean.gc.preview', {
|
||||
path: root,
|
||||
members: result.droppedMembers.length,
|
||||
graphs: result.removed.length,
|
||||
}),
|
||||
);
|
||||
if (result.keptMembers.length > 0) {
|
||||
console.log(t('clean.gc.keptMembers', { count: result.keptMembers.length }));
|
||||
}
|
||||
if (result.kept.length > 0) console.log(t('clean.shared.kept', { count: result.kept.length }));
|
||||
if (result.storeRemoved) console.log(t('clean.shared.storeRemoved', { path: root }));
|
||||
}
|
||||
if (!force) console.log(`\n${t('common.runForceConfirm')}`);
|
||||
};
|
||||
|
||||
export const cleanCommand = async (options?: {
|
||||
force?: boolean;
|
||||
all?: boolean;
|
||||
lbugSidecars?: boolean;
|
||||
stale?: boolean;
|
||||
branch?: string;
|
||||
gc?: boolean;
|
||||
localIndex?: boolean;
|
||||
}) => {
|
||||
if (options?.gc) {
|
||||
await collectSharedStores(options.force === true);
|
||||
return;
|
||||
}
|
||||
|
||||
// --local-index: delete a pre-adoption index left in <checkout>/.gitnexus
|
||||
// after the checkout moved into a shared store (#3352). Keeps the pointer.
|
||||
if (options?.localIndex) {
|
||||
const repo = await findRepo(process.cwd());
|
||||
if (!repo) {
|
||||
console.log(t('clean.notFoundHere'));
|
||||
return;
|
||||
}
|
||||
const legacy = await findLegacyLocalIndex(repo.repoPath, repo.storagePath);
|
||||
if (!legacy) {
|
||||
console.log(t('clean.localIndex.none'));
|
||||
return;
|
||||
}
|
||||
if (!options.force) {
|
||||
console.log(
|
||||
t('clean.localIndex.preview', { path: legacy.dir, size: formatSlotSize(legacy.bytes) }),
|
||||
);
|
||||
console.log(`\n${t('common.runForceConfirm')}`);
|
||||
return;
|
||||
}
|
||||
await removeLegacyLocalIndex(repo.repoPath, repo.storagePath);
|
||||
console.log(
|
||||
t('clean.localIndex.deleted', { path: legacy.dir, size: formatSlotSize(legacy.bytes) }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// --stale: reclaim leftover per-branch slots whose recorded branch is not
|
||||
// a live local head (#3331). Exclusive arm before --branch.
|
||||
if (options?.stale) {
|
||||
|
|
@ -292,9 +390,12 @@ export const cleanCommand = async (options?: {
|
|||
for (const entry of entries) {
|
||||
try {
|
||||
const storagePath = await requireDeletableStoragePath(entry);
|
||||
await fs.rm(storagePath, { recursive: true, force: true });
|
||||
await unregisterRepo(entry.path);
|
||||
// A shared slot is unregistered before it is deleted: its lock file
|
||||
// lives inside it, so it must go last. A failed delete throws with the
|
||||
// `clean --gc --force` recovery (shared-store-clean.test.ts).
|
||||
await removeCheckoutStorage(storagePath, () => unregisterRepo(entry.path), entry.path);
|
||||
console.log(t('clean.deletedRepo', { name: entry.name, storagePath }));
|
||||
reportReclaim(await reclaimAfterSlotRemoval(storagePath));
|
||||
} catch (err) {
|
||||
if (err instanceof StorageDeletionError) {
|
||||
logger.error(`Refusing to clean ${entry.name}: ${err.message}`);
|
||||
|
|
@ -338,9 +439,9 @@ export const cleanCommand = async (options?: {
|
|||
}
|
||||
|
||||
try {
|
||||
await fs.rm(storagePath, { recursive: true, force: true });
|
||||
await unregisterRepo(repo.repoPath);
|
||||
await removeCheckoutStorage(storagePath, () => unregisterRepo(repo.repoPath), repo.repoPath);
|
||||
console.log(t('common.deleted', { target: storagePath }));
|
||||
reportReclaim(await reclaimAfterSlotRemoval(storagePath));
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to delete:');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js';
|
||||
import { findLegacyLocalIndex } from '../storage/shared-store-lifecycle.js';
|
||||
import { resolveEmbeddingConfig } from '../core/embeddings/config.js';
|
||||
import { isHttpMode } from '../core/embeddings/http-client.js';
|
||||
import {
|
||||
|
|
@ -411,6 +412,14 @@ export const doctorCommand = async () => {
|
|||
branches: entry?.branches,
|
||||
});
|
||||
const leftoverLines = leftoverBranchSlotDoctorLines(slots);
|
||||
// A pre-adoption index left in <repo>/.gitnexus after this checkout moved
|
||||
// into a shared store (#3352).
|
||||
const legacy = await findLegacyLocalIndex(cwdRepo.repoPath, cwdRepo.storagePath);
|
||||
if (legacy) {
|
||||
leftoverLines.push(
|
||||
t('status.legacyLocalIndex', { path: legacy.dir, size: formatSlotSize(legacy.bytes) }),
|
||||
);
|
||||
}
|
||||
if (leftoverLines.length === 0) return;
|
||||
console.log('');
|
||||
for (const line of leftoverLines) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { lstat } from 'node:fs/promises';
|
||||
import { ensurePrivateSharedGraph } from '../core/shared-store-analyze.js';
|
||||
import { LBUG_DIRECTORY } from '../storage/storage-constants.js';
|
||||
import path from 'node:path';
|
||||
import { cliInfo } from './cli-message.js';
|
||||
import { getGitRoot } from '../storage/git.js';
|
||||
|
|
@ -43,14 +45,20 @@ export const embeddingsSyncCommand = async (inputPath?: string): Promise<void> =
|
|||
const repoPath = inputPath ? path.resolve(inputPath) : getGitRoot(process.cwd());
|
||||
if (!repoPath) throw new Error('Not inside a git repository. Pass a repository path.');
|
||||
|
||||
const { lbugPath, metaPath } = getStoragePaths(repoPath);
|
||||
const { metaPath } = getStoragePaths(repoPath);
|
||||
const metaDir = path.dirname(metaPath);
|
||||
// Writes go to the slot's own graph. A shared-store checkout that reads an
|
||||
// immutable commit graph (#3352) takes a private copy first.
|
||||
const lbugPath = path.join(metaDir, LBUG_DIRECTORY);
|
||||
const lock = await acquireIndexLock(metaDir);
|
||||
try {
|
||||
requireExclusiveIndexLock(
|
||||
lock,
|
||||
`Cannot acquire the index lock at ${metaDir}; refusing an unlocked embeddings sync.`,
|
||||
);
|
||||
if (!(await ensurePrivateSharedGraph(metaDir, (m) => console.log(` ${m}`)))) {
|
||||
throw new Error('The shared graph this checkout reads is gone. Run gitnexus analyze first.');
|
||||
}
|
||||
const meta = await loadMeta(metaDir);
|
||||
if (!meta)
|
||||
throw new Error(`No GitNexus index found for ${repoPath}. Run gitnexus analyze first.`);
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ const OPTION_DESCRIPTION_KEYS = {
|
|||
'analyze|--skip-git': 'help.option.skipGit',
|
||||
'analyze|--name <alias>': 'help.option.analyze.name',
|
||||
'analyze|--allow-duplicate-name': 'help.option.analyze.allowDuplicateName',
|
||||
'analyze|--share-with <repo>': 'help.option.analyze.shareWith',
|
||||
'analyze|--no-share': 'help.option.analyze.noShare',
|
||||
'analyze|-v, --verbose': 'help.option.verbose',
|
||||
'analyze|--max-file-size <kb>': 'help.option.analyze.maxFileSize',
|
||||
'analyze|--worker-timeout <seconds>': 'help.option.analyze.workerTimeout',
|
||||
|
|
@ -97,6 +99,8 @@ const OPTION_DESCRIPTION_KEYS = {
|
|||
'clean|--branch <name>': 'help.option.clean.branch',
|
||||
'clean|--lbug-sidecars': 'help.option.clean.lbugSidecars',
|
||||
'clean|--stale': 'help.option.clean.stale',
|
||||
'clean|--gc': 'help.option.clean.gc',
|
||||
'clean|--local-index': 'help.option.clean.localIndex',
|
||||
'remove|-f, --force': 'help.option.force.confirmation',
|
||||
'wiki|-f, --force': 'help.option.wiki.force',
|
||||
'wiki|--provider <provider>': 'help.option.wiki.provider',
|
||||
|
|
|
|||
|
|
@ -28,6 +28,15 @@ export const en = {
|
|||
'list.clusters': 'Clusters',
|
||||
'list.processes': 'Processes',
|
||||
'list.unknown': 'unknown',
|
||||
'status.sharedStoreShared': 'Shared index: store {{key}}, shared graph for commit {{commit}}',
|
||||
'status.sharedStorePrivate':
|
||||
'Shared index: store {{key}}, private graph (local changes or a pinned branch index)',
|
||||
'status.sharedStoreCloneCow':
|
||||
' Copied copy-on-write: unchanged pages are shared with the commit graph on disk',
|
||||
'status.sharedStoreCloneCopy':
|
||||
' Full copy: this filesystem cannot clone copy-on-write (APFS, btrfs and XFS can)',
|
||||
'status.legacyLocalIndex':
|
||||
'Leftover local index: {{path}} ({{size}}); remove it with `gitnexus clean --local-index --force`',
|
||||
'status.notGitRepo': 'Not a git repository.',
|
||||
'status.staleKuzu': 'Repository has a stale KuzuDB index from a previous version.',
|
||||
'status.rebuildLadybug': 'Run: gitnexus analyze (rebuilds the index with LadybugDB)',
|
||||
|
|
@ -59,6 +68,22 @@ export const en = {
|
|||
'clean.deleteAll': 'This will delete GitNexus indexes for {{count}} repo(s):',
|
||||
'clean.deletedRepo': 'Deleted: {{name}} ({{storagePath}})',
|
||||
'clean.notFoundHere': 'No indexed repository found in this directory.',
|
||||
'clean.shared.reclaimed':
|
||||
'Shared store: removed {{count}} commit graph(s) no checkout references.',
|
||||
'clean.shared.kept':
|
||||
'Shared store: kept {{count}} unreferenced commit graph(s) that could not be removed (in use or not writable); run `gitnexus clean --gc` later.',
|
||||
'clean.shared.storeRemoved': 'Shared store: removed {{path}} (no checkouts remain).',
|
||||
'clean.gc.none': 'No shared stores to collect.',
|
||||
'clean.gc.keptMembers':
|
||||
'Shared store: kept {{count}} checkout(s) it could not delete; run `gitnexus clean --gc --force` later.',
|
||||
'clean.gc.store':
|
||||
'Shared store {{path}}: dropped {{members}} checkout(s), removed {{graphs}} commit graph(s).',
|
||||
'clean.gc.preview':
|
||||
'Shared store {{path}}: would drop {{members}} checkout(s) and remove {{graphs}} commit graph(s).',
|
||||
'clean.localIndex.none': 'No leftover local index in this checkout.',
|
||||
'clean.localIndex.preview':
|
||||
'This will delete the leftover local index at {{path}} ({{size}}). The shared index is not affected.',
|
||||
'clean.localIndex.deleted': 'Deleted the leftover local index at {{path}} ({{size}}).',
|
||||
'clean.deleteCurrent': 'This will delete the GitNexus index for: {{repoName}}',
|
||||
'clean.branchNotIndexed':
|
||||
'No indexed branch named "{{branch}}" for this repository. Use `gitnexus clean --stale` to reclaim leftover branch indexes, or `gitnexus list` to see recorded names.',
|
||||
|
|
@ -271,6 +296,10 @@ export const en = {
|
|||
'Register this repo under a custom name in ~/.gitnexus/registry.json (disambiguates repos whose paths share a basename, e.g. two different .../app folders)',
|
||||
'help.option.analyze.allowDuplicateName':
|
||||
'Register this repo even if another path already uses the same --name alias. Leaves `-r <name>` ambiguous for the two paths; use -r <path> to disambiguate.',
|
||||
'help.option.analyze.shareWith':
|
||||
'Join the shared index store of a registered checkout of the same repository (name or path); the remote URL must match. Clones join a sibling clone’s store automatically; this names one explicitly and clears a --no-share opt-out.',
|
||||
'help.option.analyze.noShare':
|
||||
'Clones only: leave the shared index store, index into <repo>/.gitnexus again, and stop joining sibling clones automatically until --share-with (linked worktrees always share; set GITNEXUS_SHARED_STORE=off instead)',
|
||||
'help.option.verbose': 'Enable verbose output',
|
||||
'help.option.analyze.maxFileSize':
|
||||
'Skip files larger than this (KB). Default: 512. Hard cap: 32768 (tree-sitter limit).',
|
||||
|
|
@ -310,6 +339,10 @@ export const en = {
|
|||
'help.option.clean.lbugSidecars':
|
||||
'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)',
|
||||
'help.option.clean.stale': 'Reclaim leftover branch indexes that are not a live local head',
|
||||
'help.option.clean.gc':
|
||||
'Drop shared-store checkouts no registry entry uses and delete commit graphs nothing references',
|
||||
'help.option.clean.localIndex':
|
||||
'Delete the index left in <repo>/.gitnexus after this checkout moved into a shared store',
|
||||
'help.option.wiki.force': 'Force full regeneration even if up to date',
|
||||
'help.option.wiki.provider':
|
||||
'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)',
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@ export const zhCN = {
|
|||
'list.clusters': '聚类',
|
||||
'list.processes': '流程',
|
||||
'list.unknown': 'unknown',
|
||||
'status.sharedStoreShared': '共享索引:存储 {{key}},提交 {{commit}} 的共享图',
|
||||
'status.sharedStorePrivate': '共享索引:存储 {{key}},私有图(有本地更改或固定分支索引)',
|
||||
'status.sharedStoreCloneCow': ' 写时复制副本:未更改的页面在磁盘上与提交图共享',
|
||||
'status.sharedStoreCloneCopy':
|
||||
' 完整副本:此文件系统不支持写时复制克隆(APFS、btrfs 和 XFS 支持)',
|
||||
'status.legacyLocalIndex':
|
||||
'残留的本地索引:{{path}}({{size}});使用 `gitnexus clean --local-index --force` 删除',
|
||||
'status.notGitRepo': '当前目录不是 git 仓库。',
|
||||
'status.staleKuzu': '仓库包含旧版本遗留的 KuzuDB 索引。',
|
||||
'status.rebuildLadybug': '运行:gitnexus analyze (使用 LadybugDB 重建索引)',
|
||||
|
|
@ -58,6 +65,18 @@ export const zhCN = {
|
|||
'clean.deleteAll': '将删除 {{count}} 个仓库的 GitNexus 索引:',
|
||||
'clean.deletedRepo': '已删除:{{name}}({{storagePath}})',
|
||||
'clean.notFoundHere': '当前目录未找到已索引仓库。',
|
||||
'clean.shared.reclaimed': '共享存储:已删除 {{count}} 个不再被任何检出引用的提交图。',
|
||||
'clean.shared.kept':
|
||||
'共享存储:保留了 {{count}} 个无法删除的未引用提交图(正在使用或不可写);请稍后运行 `gitnexus clean --gc`。',
|
||||
'clean.shared.storeRemoved': '共享存储:已删除 {{path}}(没有剩余检出)。',
|
||||
'clean.gc.none': '没有可回收的共享存储。',
|
||||
'clean.gc.keptMembers':
|
||||
'共享存储:保留了 {{count}} 个无法删除的检出;请稍后运行 `gitnexus clean --gc --force`。',
|
||||
'clean.gc.store': '共享存储 {{path}}:移除了 {{members}} 个检出,删除了 {{graphs}} 个提交图。',
|
||||
'clean.gc.preview': '共享存储 {{path}}:将移除 {{members}} 个检出并删除 {{graphs}} 个提交图。',
|
||||
'clean.localIndex.none': '此检出中没有残留的本地索引。',
|
||||
'clean.localIndex.preview': '将删除 {{path}} 处残留的本地索引({{size}})。共享索引不受影响。',
|
||||
'clean.localIndex.deleted': '已删除 {{path}} 处残留的本地索引({{size}})。',
|
||||
'clean.deleteCurrent': '将删除该仓库的 GitNexus 索引:{{repoName}}',
|
||||
'clean.branchNotIndexed':
|
||||
'该仓库没有名为 “{{branch}}” 的已索引分支。使用 `gitnexus clean --stale` 回收残留分支索引,或使用 `gitnexus list` 查看已记录名称。',
|
||||
|
|
@ -251,6 +270,10 @@ export const zhCN = {
|
|||
'在 ~/.gitnexus/registry.json 中使用自定义名称注册该仓库(用于区分路径 basename 相同的仓库,例如两个不同的 .../app 目录)',
|
||||
'help.option.analyze.allowDuplicateName':
|
||||
'即使已有其他路径使用相同 --name 别名,也注册该仓库。会使两个路径的 `-r <name>` 产生歧义;请用 -r <path> 消除歧义。',
|
||||
'help.option.analyze.shareWith':
|
||||
'加入同一仓库已注册检出的共享索引存储(名称或路径);远程 URL 必须一致。克隆会自动加入同源克隆的存储;此选项显式指定存储,并清除 --no-share 的退出设置。',
|
||||
'help.option.analyze.noShare':
|
||||
'仅限克隆:离开共享索引存储,重新索引到 <repo>/.gitnexus,并在使用 --share-with 之前不再自动加入同源克隆(链接工作树始终共享;请改用 GITNEXUS_SHARED_STORE=off)',
|
||||
'help.option.verbose': '启用详细输出',
|
||||
'help.option.analyze.maxFileSize':
|
||||
'跳过大于该值的文件(KB)。默认:512。硬上限:32768(tree-sitter 限制)。',
|
||||
|
|
@ -287,6 +310,8 @@ export const zhCN = {
|
|||
'help.option.clean.lbugSidecars':
|
||||
'清理已暂存的 LadybugDB 恢复 sidecar(missing-shadow WAL 隔离文件与 dirty-recovery 暂存文件)',
|
||||
'help.option.clean.stale': '回收已不是本地 head 的残留分支索引',
|
||||
'help.option.clean.gc': '移除注册表不再使用的共享存储检出,并删除无人引用的提交图',
|
||||
'help.option.clean.localIndex': '删除此检出迁入共享存储后遗留在 <repo>/.gitnexus 中的索引',
|
||||
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
|
||||
'help.option.wiki.provider':
|
||||
'LLM 提供商:minimax、openai、openrouter、azure、custom、cursor、claude、codex、opencode 或 grok(默认:minimax)',
|
||||
|
|
|
|||
|
|
@ -145,6 +145,18 @@ program
|
|||
'Register this repo even if another path already uses the same --name alias. ' +
|
||||
'Leaves `-r <name>` ambiguous for the two paths; use -r <path> to disambiguate.',
|
||||
)
|
||||
.option(
|
||||
'--share-with <repo>',
|
||||
'Join the shared index store of a registered checkout of the same repository ' +
|
||||
'(name or path); the remote URL must match. Clones join a sibling clone’s store ' +
|
||||
'automatically; this names one explicitly and clears a --no-share opt-out.',
|
||||
)
|
||||
.option(
|
||||
'--no-share',
|
||||
'Clones only: leave the shared index store, index into <repo>/.gitnexus again, and stop ' +
|
||||
'joining sibling clones automatically until --share-with (linked worktrees always share; ' +
|
||||
'set GITNEXUS_SHARED_STORE=off instead)',
|
||||
)
|
||||
.option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)')
|
||||
.option(
|
||||
'--max-file-size <kb>',
|
||||
|
|
@ -350,6 +362,14 @@ program
|
|||
.option('--all', 'Clean all indexed repos')
|
||||
.option('--branch <name>', 'Delete only the named branch index (not the workspace index)')
|
||||
.option('--stale', 'Reclaim leftover branch indexes that are not a live local head')
|
||||
.option(
|
||||
'--gc',
|
||||
'Drop shared-store checkouts no registry entry uses and delete commit graphs nothing references',
|
||||
)
|
||||
.option(
|
||||
'--local-index',
|
||||
'Delete the index left in <repo>/.gitnexus after this checkout moved into a shared store',
|
||||
)
|
||||
.option(
|
||||
'--lbug-sidecars',
|
||||
'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)',
|
||||
|
|
|
|||
|
|
@ -29,7 +29,10 @@
|
|||
* here there is no pipeline, so no conflation.)
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import {
|
||||
reclaimAfterSlotRemoval,
|
||||
removeCheckoutStorage,
|
||||
} from '../storage/shared-store-lifecycle.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { cliError } from './cli-message.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
|
@ -98,8 +101,8 @@ export const removeCommand = async (target: string, options?: { force?: boolean
|
|||
// orphaned — `listRegisteredRepos({ validate: true })` prunes those on
|
||||
// next read, so the failure is self-healing.
|
||||
try {
|
||||
await fs.rm(storagePath, { recursive: true, force: true });
|
||||
await unregisterRepo(entry.path);
|
||||
await removeCheckoutStorage(storagePath, () => unregisterRepo(entry.path), entry.path);
|
||||
await reclaimAfterSlotRemoval(storagePath);
|
||||
console.log(t('remove.removed', { name: entry.name }));
|
||||
console.log(` ${t('common.path')}: ${entry.path}`);
|
||||
console.log(` ${t('common.storage')}: ${entry.storagePath}`);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@
|
|||
* Shows the indexing status of the current repository.
|
||||
*/
|
||||
|
||||
import { resolveGraphPath, storeRootOfCheckoutSlot } from '../storage/shared-store.js';
|
||||
import {
|
||||
describeSharedGraph,
|
||||
findLegacyLocalIndex,
|
||||
readGraphCloneKind,
|
||||
} from '../storage/shared-store-lifecycle.js';
|
||||
import { formatSlotSize } from './stale-branch-format.js';
|
||||
import path from 'path';
|
||||
import {
|
||||
getStoragePaths,
|
||||
|
|
@ -349,6 +356,28 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
!isWorkingTreeDirty(repo.repoPath));
|
||||
|
||||
const isUpToDate = metadataIsCurrent && contentIsCurrent;
|
||||
// Shared sibling store (#3352): which graph this checkout reads, and any
|
||||
// pre-adoption index still sitting in <repo>/.gitnexus.
|
||||
const storeRoot = storeRootOfCheckoutSlot(repo.storagePath);
|
||||
const sharedStore = storeRoot
|
||||
? {
|
||||
key: path.basename(storeRoot),
|
||||
// A pinned branch index (`branches/<slug>/lbug`) is always private;
|
||||
// only the flat slot can point at a shared commit graph.
|
||||
graph:
|
||||
activeMeta === repo.meta
|
||||
? describeSharedGraph(resolveGraphPath(repo.storagePath), repo.storagePath)
|
||||
: ('private' as const),
|
||||
commit: activeMeta.lastCommit,
|
||||
}
|
||||
: null;
|
||||
// A private flat graph copied from a shared one: say whether the filesystem
|
||||
// shared its unchanged pages (copy-on-write) or it is a full copy.
|
||||
const privateClone =
|
||||
sharedStore?.graph === 'private' && activeMeta === repo.meta
|
||||
? await readGraphCloneKind(repo.storagePath)
|
||||
: null;
|
||||
const legacyLocalIndex = await findLegacyLocalIndex(repo.repoPath, repo.storagePath);
|
||||
if (options.json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
|
|
@ -369,6 +398,10 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
runnerIdentity: currentRunnerIdentity,
|
||||
},
|
||||
contentDrift: describeContentDrift(contentDrift),
|
||||
sharedStore: sharedStore ? { ...sharedStore, privateClone } : null,
|
||||
legacyLocalIndex: legacyLocalIndex
|
||||
? { path: legacyLocalIndex.dir, bytes: legacyLocalIndex.bytes }
|
||||
: null,
|
||||
status: isUpToDate ? 'up-to-date' : 'stale',
|
||||
}),
|
||||
);
|
||||
|
|
@ -382,6 +415,33 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
console.log(t('status.workspaceIndexLabel', { primary: repo.meta.branch ?? '' }));
|
||||
}
|
||||
|
||||
if (sharedStore) {
|
||||
console.log(
|
||||
sharedStore.graph === 'shared'
|
||||
? t('status.sharedStoreShared', {
|
||||
key: sharedStore.key,
|
||||
commit: sharedStore.commit.slice(0, 7),
|
||||
})
|
||||
: t('status.sharedStorePrivate', { key: sharedStore.key }),
|
||||
);
|
||||
if (privateClone) {
|
||||
console.log(
|
||||
t(
|
||||
privateClone === 'copy-on-write'
|
||||
? 'status.sharedStoreCloneCow'
|
||||
: 'status.sharedStoreCloneCopy',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (legacyLocalIndex) {
|
||||
console.log(
|
||||
t('status.legacyLocalIndex', {
|
||||
path: legacyLocalIndex.dir,
|
||||
size: formatSlotSize(legacyLocalIndex.bytes),
|
||||
}),
|
||||
);
|
||||
}
|
||||
console.log(`${t('status.indexed')}: ${new Date(activeMeta.indexedAt).toLocaleString()}`);
|
||||
console.log(`${t('status.indexedCommit')}: ${activeMeta.lastCommit?.slice(0, 7)}`);
|
||||
console.log(`${t('status.currentCommit')}: ${currentCommit?.slice(0, 7)}`);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
* - Graceful failure: any error → return empty string
|
||||
*/
|
||||
|
||||
import { resolveGraphPath } from '../../storage/shared-store.js';
|
||||
import path from 'path';
|
||||
import { listRegisteredRepos } from '../../storage/repo-manager.js';
|
||||
import {
|
||||
|
|
@ -84,7 +85,10 @@ async function findRepoForCwd(cwd: string): Promise<{
|
|||
return {
|
||||
name: bestMatch.name,
|
||||
storagePath,
|
||||
lbugPath: path.join(indexDir, LBUG_DIRECTORY),
|
||||
lbugPath:
|
||||
indexDir === storagePath
|
||||
? resolveGraphPath(storagePath)
|
||||
: path.join(indexDir, LBUG_DIRECTORY),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import { resolveGraphPath } from '../../storage/shared-store.js';
|
||||
import path from 'node:path';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import {
|
||||
|
|
@ -21,7 +22,6 @@ import {
|
|||
STATUS_STORAGE_REQUIREMENTS,
|
||||
} from '../../storage/storage-resolver.js';
|
||||
import { loadMeta } from '../../storage/repo-meta.js';
|
||||
import { LBUG_DIRECTORY } from '../../storage/storage-constants.js';
|
||||
import type {
|
||||
GroupConfig,
|
||||
RepoHandle,
|
||||
|
|
@ -381,7 +381,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
}
|
||||
|
||||
const poolId = handle.id;
|
||||
lbugPath = path.join(handle.storagePath, LBUG_DIRECTORY);
|
||||
lbugPath = resolveGraphPath(handle.storagePath);
|
||||
await initLbug(poolId, lbugPath);
|
||||
// No pin here: contract extraction below uses `executor` while this
|
||||
// repo is freshly initialized and live, and completes before the next
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import {
|
|||
import { PDG_EDGE_TYPES } from './lbug/pdg-emit-sink.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { constants as fsConstants, existsSync } from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { retryRename } from '../storage/fs-atomic.js';
|
||||
import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js';
|
||||
|
|
@ -146,6 +146,7 @@ import {
|
|||
loadMeta,
|
||||
ensureGitNexusIgnored,
|
||||
registerRepo,
|
||||
setShareOptOut,
|
||||
adoptFlatBranchLabel,
|
||||
isReadOnlyFilesystemError,
|
||||
isRepoRegistered,
|
||||
|
|
@ -162,8 +163,30 @@ import {
|
|||
import {
|
||||
ANALYZE_FORCE_STORAGE_REQUIREMENTS,
|
||||
ANALYZE_STORAGE_REQUIREMENTS,
|
||||
defaultStoragePath,
|
||||
requireRegisteredStoragePath,
|
||||
requireStoragePath,
|
||||
resolveStoragePath,
|
||||
} from '../storage/storage-resolver.js';
|
||||
import {
|
||||
isSharedStoreDisabled,
|
||||
resolveGraphPath,
|
||||
resolveSharedStore,
|
||||
storeRootOfCheckoutSlot,
|
||||
type SharedStoreLayout,
|
||||
} from '../storage/shared-store.js';
|
||||
import { LBUG_DIRECTORY } from '../storage/storage-constants.js';
|
||||
import {
|
||||
ensurePrivateSharedGraph,
|
||||
listStoreMetaRoots,
|
||||
leaveSharedStore,
|
||||
optedInSlotToLeave,
|
||||
registerLeftStore,
|
||||
publishSharedGraph,
|
||||
resolveOptedInStore,
|
||||
seedSharedSlot,
|
||||
} from './shared-store-analyze.js';
|
||||
import { withStoreLock } from '../storage/shared-store-lifecycle.js';
|
||||
import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js';
|
||||
import {
|
||||
DEFAULT_MAX_CFG_EDGES_PER_FUNCTION,
|
||||
|
|
@ -528,6 +551,13 @@ export interface AnalyzeOptions {
|
|||
* of a pipeline re-index.
|
||||
*/
|
||||
allowDuplicateName?: boolean;
|
||||
/**
|
||||
* Join the shared store of this registered worktree (name or path), after
|
||||
* checking the remote URL matches (#3352). Persisted through the registry.
|
||||
*/
|
||||
shareWith?: string;
|
||||
/** Leave the shared store and index into `<repo>/.gitnexus` (#3352). */
|
||||
noShare?: boolean;
|
||||
/**
|
||||
* Worker pool size override, threaded from the CLI `--workers` flag.
|
||||
* Forwarded to `PipelineOptions.workerPoolSize` so the parse phase
|
||||
|
|
@ -1085,6 +1115,8 @@ interface WriteTarget {
|
|||
lbugPath: string;
|
||||
metaPath: string;
|
||||
metaDir: string;
|
||||
/** Set when this checkout writes into a shared sibling store (#3352). */
|
||||
sharedStore?: SharedStoreLayout;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1101,10 +1133,35 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr
|
|||
// a cached path string must not skip ownership (STORAGE_PATH can move to a
|
||||
// foreign slot while the lock is waited out). `--force` may adopt a
|
||||
// repository-local foreign slot; the non-force set stays ANALYZE_STORAGE.
|
||||
const storagePath = await requireStoragePath(
|
||||
repoPath,
|
||||
options.force ? ANALYZE_FORCE_STORAGE_REQUIREMENTS : ANALYZE_STORAGE_REQUIREMENTS,
|
||||
);
|
||||
// A linked-worktree checkout writes its own slot in the shared store
|
||||
// (#3352); that slot replaces any repository-local `.gitnexus`, which is left
|
||||
// untouched.
|
||||
const storageRequirements = options.force
|
||||
? ANALYZE_FORCE_STORAGE_REQUIREMENTS
|
||||
: ANALYZE_STORAGE_REQUIREMENTS;
|
||||
if (options.noShare && resolveSharedStore(repoPath)) {
|
||||
// Fail before any lock or indexing; only an opted-in clone can leave.
|
||||
throw new Error(
|
||||
'--no-share: linked worktrees always use the shared index store. ' +
|
||||
'Set GITNEXUS_SHARED_STORE=off to index every checkout into its own .gitnexus.',
|
||||
);
|
||||
}
|
||||
const sharingOff = options.noShare || isSharedStoreDisabled();
|
||||
const sharedStore = sharingOff
|
||||
? undefined
|
||||
: (resolveSharedStore(repoPath) ?? (await resolveOptedInStore(repoPath, options.shareWith)));
|
||||
// A checkout still registered in a store after sharing was turned off
|
||||
// indexes into its own `.gitnexus` again; its slot is left for `clean --gc`.
|
||||
const leavingStore =
|
||||
!sharedStore && storeRootOfCheckoutSlot(resolveStoragePath(repoPath)) !== null;
|
||||
const explicitStorage =
|
||||
sharedStore?.checkoutSlot ?? (leavingStore ? defaultStoragePath(repoPath) : undefined);
|
||||
const storagePath = explicitStorage
|
||||
? await requireRegisteredStoragePath(
|
||||
{ path: repoPath, storagePath: explicitStorage },
|
||||
storageRequirements,
|
||||
)
|
||||
: await requireStoragePath(repoPath, storageRequirements);
|
||||
const repoHasGit = hasGitDir(repoPath);
|
||||
const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : '';
|
||||
// Normalize the auto-detected branch the same way an explicit `--branch` is
|
||||
|
|
@ -1131,7 +1188,13 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr
|
|||
const placement = options.branch
|
||||
? await resolveBranchPlacement(repoPath, branchLabel, storagePath)
|
||||
: {};
|
||||
const { lbugPath, metaPath } = getStoragePaths(repoPath, placement.branch, storagePath);
|
||||
const paths = getStoragePaths(repoPath, placement.branch, storagePath);
|
||||
const { metaPath } = paths;
|
||||
// Analyze always writes a store slot's own graph; a recorded `graphPath`
|
||||
// (an immutable commit graph) only redirects readers.
|
||||
const lbugPath = storeRootOfCheckoutSlot(storagePath)
|
||||
? path.join(path.dirname(metaPath), LBUG_DIRECTORY)
|
||||
: paths.lbugPath;
|
||||
return {
|
||||
storagePath,
|
||||
repoHasGit,
|
||||
|
|
@ -1143,6 +1206,7 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr
|
|||
lbugPath,
|
||||
metaPath,
|
||||
metaDir: path.dirname(metaPath),
|
||||
sharedStore,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1253,7 +1317,10 @@ export async function runFullAnalysis(
|
|||
`Warning: checkout "${formatRejectedBranchForLog(writeTarget.rejectedDetectedBranch)}" is not a usable index label; continuing.`,
|
||||
);
|
||||
}
|
||||
return await runFullAnalysisInner(
|
||||
const flatShared = writeTarget.placement.branch ? undefined : writeTarget.sharedStore;
|
||||
if (flatShared) await seedSharedSlot(flatShared, repoPath, log);
|
||||
const slotToLeave = options.noShare ? await optedInSlotToLeave(repoPath) : undefined;
|
||||
const result = await runFullAnalysisInner(
|
||||
repoPath,
|
||||
options,
|
||||
callbacks,
|
||||
|
|
@ -1261,6 +1328,24 @@ export async function runFullAnalysis(
|
|||
contentRetention,
|
||||
runnerIdentityAtBootstrap,
|
||||
);
|
||||
if (flatShared) {
|
||||
await publishSharedGraph(flatShared, repoPath, writeTarget.currentCommit, log);
|
||||
} else if (!writeTarget.sharedStore) {
|
||||
// Also for a `--branch` run routed to a local branch sub-slot: the
|
||||
// checkout still leaves the store.
|
||||
// Leaving a store (`--no-share`, or sharing turned off): the up-to-date
|
||||
// path does not re-register, so point the registry at the new storage
|
||||
// before the old slot goes away.
|
||||
if (slotToLeave) {
|
||||
await leaveSharedStore(repoPath, slotToLeave, writeTarget.storagePath, log);
|
||||
} else {
|
||||
await registerLeftStore(repoPath, writeTarget.storagePath);
|
||||
}
|
||||
}
|
||||
// A clone that left stays out of sibling stores until `--share-with`.
|
||||
if (options.noShare) await setShareOptOut(repoPath, true);
|
||||
else if (options.shareWith) await setShareOptOut(repoPath, false);
|
||||
return result;
|
||||
} finally {
|
||||
discardScopedEmbeddingSpills();
|
||||
lock.release();
|
||||
|
|
@ -1294,6 +1379,9 @@ async function runFullAnalysisInner(
|
|||
// does not own the flat slot. See resolveWriteTarget for the full contract.
|
||||
const { storagePath, repoHasGit, currentCommit, branchLabel, placement, lbugPath, metaDir } =
|
||||
writeTarget;
|
||||
// Content-addressed caches live once per shared store (#3352), else in the
|
||||
// flat slot shared by its branch slots (#2106 KTD7).
|
||||
const cacheRoot = writeTarget.sharedStore?.cachesDir ?? storagePath;
|
||||
let storageWritable: Promise<void> | undefined;
|
||||
const ensureWritableStorage = (): Promise<void> => {
|
||||
storageWritable ??= ensureStoragePathWritable(storagePath);
|
||||
|
|
@ -1326,7 +1414,20 @@ async function runFullAnalysisInner(
|
|||
log(`Metadata reconciliation failed (non-critical${code ? `, ${code}` : ''}); continuing.`);
|
||||
}
|
||||
|
||||
// Shared-store pointer slots (#3352) get a private graph just before the
|
||||
// first graph open: here for the paths that open it before the up-to-date
|
||||
// check, and below once that check falls through.
|
||||
const ensurePrivateGraph = async (copy = true): Promise<void> => {
|
||||
if (!writeTarget.sharedStore || placement.branch) return;
|
||||
if (!(await ensurePrivateSharedGraph(metaDir, log, { copy }))) {
|
||||
options = { ...options, force: true };
|
||||
}
|
||||
// Later dirty-flag writes spread the in-memory metadata; keep them from
|
||||
// re-recording the pointer this slot just left.
|
||||
delete loadedMeta?.graphPath;
|
||||
};
|
||||
const loadedMeta = await loadMeta(metaDir);
|
||||
if (loadedMeta?.incrementalInProgress || options.repairFts) await ensurePrivateGraph();
|
||||
if (options.preserveExistingPdg && options.pdg === undefined) {
|
||||
if (loadedMeta) {
|
||||
options = { ...options, pdg: loadedMeta.pdg !== undefined };
|
||||
|
|
@ -2109,6 +2210,20 @@ async function runFullAnalysisInner(
|
|||
processDetectionBudget,
|
||||
);
|
||||
|
||||
// A shared-store slot (#3352) can record HEAD with no graph behind it: a
|
||||
// publish interrupted between its renames, or a commit graph reclaimed from
|
||||
// under the pointer. Neither the fast path nor an incremental diff (which
|
||||
// writes only changed files into a fresh, empty database) would restore it,
|
||||
// so rebuild. Scoped to store slots: private `.gitnexus` indexes only lose
|
||||
// their graph by hand, and their metadata-only fixtures rely on this path.
|
||||
if (existingMeta && !options.force && storeRootOfCheckoutSlot(storagePath)) {
|
||||
const graph = placement.branch ? lbugPath : resolveGraphPath(storagePath);
|
||||
if (!existsSync(graph)) {
|
||||
log('Shared store: this checkout has no graph; doing a full build.');
|
||||
options = { ...options, force: true };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Early-return: already up to date ──────────────────────────────
|
||||
if (
|
||||
existingMeta &&
|
||||
|
|
@ -2280,6 +2395,13 @@ async function runFullAnalysisInner(
|
|||
}
|
||||
|
||||
await ensureWritableStorage();
|
||||
// A forced rebuild reads the old graph only to carry embeddings over; with
|
||||
// none to carry, copying the shared graph would be thrown away unread.
|
||||
const forcedRebuildReadsOldGraph =
|
||||
resumeEmbeddingCheckpoint ||
|
||||
_deriveEmbeddingMode(options, existingMeta?.stats?.embeddings ?? 0).shouldLoadCache;
|
||||
await ensurePrivateGraph(!options.force || forcedRebuildReadsOldGraph);
|
||||
delete existingMeta?.graphPath;
|
||||
|
||||
// ── Cache embeddings from existing index before rebuild ────────────
|
||||
// Four modes:
|
||||
|
|
@ -2382,13 +2504,13 @@ async function runFullAnalysisInner(
|
|||
// after success. Unique because index locks are per branch slot while this
|
||||
// cache root is shared across branches.
|
||||
if (options.useParseCache === false) {
|
||||
coldParseRebuildDir = await createColdParseRebuildDir(storagePath);
|
||||
coldParseRebuildDir = await createColdParseRebuildDir(cacheRoot);
|
||||
forgetCreatedParseCacheDir(coldParseRebuildDir);
|
||||
}
|
||||
const parseCache =
|
||||
options.useParseCache === false
|
||||
? emptyParseCache(coldParseRebuildDir)
|
||||
: await loadParseCache(storagePath);
|
||||
: await loadParseCache(cacheRoot);
|
||||
|
||||
// Streamed structural emit (#2680). Resolved ONCE, so the pipeline flag and
|
||||
// the CSV-dir resolution below cannot disagree — and resolved HERE, not at
|
||||
|
|
@ -4796,15 +4918,27 @@ async function runFullAnalysisInner(
|
|||
// so the cache file size stays bounded across runs (chunks whose
|
||||
// composition no longer matches anything in the current scan are dead
|
||||
// weight; the parse phase populates `usedKeys` as it processes chunks).
|
||||
try {
|
||||
const saveCaches = async (): Promise<void> => {
|
||||
// #2106 R6: the parse cache + durable store are shared across branches.
|
||||
// Before pruning to this run's keys, fold in the OTHER branches' recorded
|
||||
// chunk keys so a branch switch doesn't evict their still-live shards.
|
||||
// Adding to usedKeys makes them survive pruneCache AND land in the saved
|
||||
// index (saveParseCache builds the index from usedKeys). Excludes this
|
||||
// run's own meta dir, so a single-branch repo folds in nothing → prune
|
||||
// set byte-identical to today.
|
||||
const { keys: siblingKeys, complete } = await collectBranchCacheKeys(storagePath, metaDir);
|
||||
// set byte-identical to today. A shared store (#3352) folds in every
|
||||
// member checkout and commit graph the same way.
|
||||
// An unlistable store directory starts the fold incomplete, so the
|
||||
// retention branch below keeps other slots' chunks.
|
||||
const listing = writeTarget.sharedStore
|
||||
? await listStoreMetaRoots(writeTarget.sharedStore)
|
||||
: { roots: [storagePath], complete: true };
|
||||
const siblingKeys = new Set<string>();
|
||||
let complete = listing.complete;
|
||||
for (const root of listing.roots) {
|
||||
const folded = await collectBranchCacheKeys(root, metaDir);
|
||||
for (const k of folded.keys) siblingKeys.add(k);
|
||||
if (!folded.complete) complete = false;
|
||||
}
|
||||
if (complete) {
|
||||
for (const k of siblingKeys) parseCache.usedKeys.add(k);
|
||||
} else {
|
||||
|
|
@ -4817,7 +4951,7 @@ async function runFullAnalysisInner(
|
|||
if (pruned > 0) {
|
||||
log(`Parse cache: pruned ${pruned} stale chunk entries`);
|
||||
}
|
||||
const savedKeys = await saveParseCache(storagePath, parseCache);
|
||||
const savedKeys = await saveParseCache(cacheRoot, parseCache);
|
||||
// Prune the durable ParsedFile store to EXACTLY the parse cache's
|
||||
// surviving keys (#2038 warm-cache coverage), so the two content-addressed
|
||||
// stores stay coherent: a chunk is "cached" iff both its parse-cache shard
|
||||
|
|
@ -4828,11 +4962,16 @@ async function runFullAnalysisInner(
|
|||
// durable-store write must never
|
||||
// break an otherwise successful run (next run treats it as a miss).
|
||||
await mergeStagedDurableParsedFileStore(
|
||||
storagePath,
|
||||
parseCache.storagePath ?? storagePath,
|
||||
cacheRoot,
|
||||
parseCache.storagePath ?? cacheRoot,
|
||||
PARSE_CACHE_VERSION,
|
||||
new Set(savedKeys),
|
||||
);
|
||||
};
|
||||
try {
|
||||
await (writeTarget.sharedStore
|
||||
? withStoreLock(writeTarget.sharedStore, 'cache', saveCaches)
|
||||
: saveCaches());
|
||||
} catch (e) {
|
||||
log(`Warning: could not save parse cache (${(e as Error).message}); continuing.`);
|
||||
}
|
||||
|
|
|
|||
656
gitnexus/src/core/shared-store-analyze.ts
Normal file
656
gitnexus/src/core/shared-store-analyze.ts
Normal file
|
|
@ -0,0 +1,656 @@
|
|||
/**
|
||||
* Analyze-side operations for the shared sibling store (#3352).
|
||||
*
|
||||
* A shared checkout slot is in one of two states:
|
||||
* - pointer: metadata records `graphPath` (a commit graph in the store);
|
||||
* the slot has no graph of its own.
|
||||
* - private: the slot owns `<slot>/lbug`; no `graphPath`.
|
||||
*
|
||||
* Analyze always writes the private path. `ensurePrivateSharedGraph` turns a
|
||||
* pointer into a private copy just before the first graph open/write, so a
|
||||
* clean checkout that hits the up-to-date fast path copies nothing.
|
||||
* `publishSharedGraph` runs after a successful analyze: a clean checkout at
|
||||
* HEAD moves its private graph into `commits/` (or drops it when that commit
|
||||
* graph already exists) and becomes a pointer again. Commit graphs are never
|
||||
* written after publish.
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { existsSync, constants as fsConstants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js';
|
||||
import {
|
||||
commitDistanceToHead,
|
||||
getRemoteUrl,
|
||||
hasGitDir,
|
||||
isWorkingTreePristine,
|
||||
} from '../storage/git.js';
|
||||
import {
|
||||
canonicalizePath,
|
||||
findRegistryEntryByRepoPath,
|
||||
readRegistry,
|
||||
registerRepo,
|
||||
registryPathEquals,
|
||||
resolveRegistryEntry,
|
||||
saveMeta,
|
||||
type RegistryEntry,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { isMissingFilesystemError, loadMeta, type RepoMeta } from '../storage/repo-meta.js';
|
||||
import {
|
||||
cloneStoreKey,
|
||||
commitGraphDir,
|
||||
resolveGraphPath,
|
||||
resolveSharedStore,
|
||||
sharedStoreLayout,
|
||||
storeRootOfCheckoutSlot,
|
||||
type SharedStoreLayout,
|
||||
} from '../storage/shared-store.js';
|
||||
import {
|
||||
GRAPH_CLONE_MARKER,
|
||||
type GraphCloneKind,
|
||||
reclaimAfterSlotRemoval,
|
||||
reclaimSharedStoreLocked,
|
||||
removeSharedStorePointer,
|
||||
withStoreLock,
|
||||
writeSharedStorePointer,
|
||||
} from '../storage/shared-store-lifecycle.js';
|
||||
import { GITNEXUS_DIR, INDEX_METADATA_FILE, LBUG_DIRECTORY } from '../storage/storage-constants.js';
|
||||
import { wipeLbugDbFiles } from './lbug/lbug-adapter.js';
|
||||
import { inspectLbugSidecars } from './lbug/sidecar-recovery.js';
|
||||
|
||||
type Log = (msg: string) => void;
|
||||
|
||||
/**
|
||||
* Fields that differ between checkouts or runs of the same content and
|
||||
* settings. Everything else in the metadata — schema fingerprint, analysis
|
||||
* features, capabilities, retention, runner identity, PDG and process
|
||||
* settings — must match for two checkouts to share a graph. A denylist fails
|
||||
* safe: an unexpected per-run field only prevents sharing, never mixes graphs.
|
||||
*/
|
||||
const FEATURE_KEY_EXCLUDED = new Set<string>([
|
||||
'repoPath',
|
||||
'storagePath',
|
||||
'graphPath',
|
||||
'lastCommit',
|
||||
'indexedAt',
|
||||
'branch',
|
||||
'remoteUrl',
|
||||
'fileHashes',
|
||||
'cacheKeys',
|
||||
'incrementalInProgress',
|
||||
'embeddingCheckpoint',
|
||||
'stats',
|
||||
]);
|
||||
|
||||
/** Fields that describe one checkout; stripped from a published commit graph's metadata. */
|
||||
const CHECKOUT_FIELDS = ['repoPath', 'storagePath', 'graphPath', 'branch', 'incrementalInProgress'];
|
||||
|
||||
const stableStringify = (value: unknown): string => {
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
||||
if (value && typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
|
||||
/** Hash of every graph-affecting metadata field (see FEATURE_KEY_EXCLUDED). */
|
||||
export const featureKeyOf = (meta: RepoMeta): string => {
|
||||
const kept: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(meta)) {
|
||||
if (!FEATURE_KEY_EXCLUDED.has(k)) kept[k] = v;
|
||||
}
|
||||
// Embedding presence changes graph content but lives only in `stats`.
|
||||
kept.hasEmbeddings = (meta.stats?.embeddings ?? 0) > 0;
|
||||
return createHash('sha256').update(stableStringify(kept)).digest('hex').slice(0, 16);
|
||||
};
|
||||
|
||||
const exists = (p: string): Promise<boolean> =>
|
||||
fs.access(p).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
/**
|
||||
* Copy a graph file to `dest` via a unique `lbug.new.<id>` temp (swept by the
|
||||
* slot lock if this process dies mid-copy), cloning copy-on-write where the
|
||||
* filesystem supports it (APFS, btrfs, XFS with reflink). A clone shares every
|
||||
* unchanged page with the source, so a private graph costs only what the
|
||||
* checkout's edits rewrite; elsewhere it is a full copy. Which one happened is
|
||||
* recorded next to `dest` for `status`. On failure the temp is removed and
|
||||
* the error thrown.
|
||||
*/
|
||||
const cloneGraphFile = async (source: string, dest: string): Promise<void> => {
|
||||
const tmp = `${dest}.new.${randomUUID()}`;
|
||||
try {
|
||||
let kind: GraphCloneKind = 'copy-on-write';
|
||||
try {
|
||||
await fs.copyFile(source, tmp, fsConstants.COPYFILE_FICLONE_FORCE);
|
||||
} catch {
|
||||
kind = 'copy';
|
||||
await fs.copyFile(source, tmp);
|
||||
}
|
||||
await fs.rename(tmp, dest);
|
||||
await fs.writeFile(path.join(path.dirname(dest), GRAPH_CLONE_MARKER), kind);
|
||||
} catch (err) {
|
||||
await fs.rm(tmp, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
interface CommitGraph {
|
||||
dir: string;
|
||||
commit: string;
|
||||
meta: RepoMeta;
|
||||
}
|
||||
|
||||
const listCommitGraphs = async (layout: SharedStoreLayout): Promise<CommitGraph[]> => {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(layout.commitsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const graphs: CommitGraph[] = [];
|
||||
for (const name of names) {
|
||||
const match = /^([0-9a-f]{7,64})-([0-9a-f]{8,64})$/.exec(name);
|
||||
if (!match) continue;
|
||||
const dir = path.join(layout.commitsDir, name);
|
||||
const meta = await loadMeta(dir);
|
||||
if (!meta || !(await exists(path.join(dir, LBUG_DIRECTORY)))) continue;
|
||||
graphs.push({ dir, commit: match[1], meta });
|
||||
}
|
||||
return graphs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pick the commit graph to seed a new checkout slot from: the one at HEAD,
|
||||
* else the ancestor with the fewest commits to HEAD. Ties go to the most
|
||||
* recently indexed graph.
|
||||
* ponytail: one `git` call pair per commit graph; fine for tens of graphs,
|
||||
* batch through `git rev-list` if stores grow to hundreds.
|
||||
*/
|
||||
const pickSeed = (
|
||||
repoPath: string,
|
||||
graphs: CommitGraph[],
|
||||
): { graph: CommitGraph; distance: number } | null => {
|
||||
let best: { graph: CommitGraph; distance: number } | null = null;
|
||||
for (const graph of graphs) {
|
||||
const distance = commitDistanceToHead(repoPath, graph.commit);
|
||||
if (distance === null) continue;
|
||||
const better =
|
||||
!best ||
|
||||
distance < best.distance ||
|
||||
(distance === best.distance && graph.meta.indexedAt > best.graph.meta.indexedAt);
|
||||
if (better) best = { graph, distance };
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy a repository-local index (`<checkout>/.gitnexus`) into an empty slot
|
||||
* as its private graph. Used before the store has any commit graph: the main
|
||||
* checkout was indexed before its first worktree existed, or a worktree still
|
||||
* has its pre-store index. The source is left untouched (R12). Returns false
|
||||
* when the source is missing, not an ancestor of HEAD, busy, or not
|
||||
* consolidated.
|
||||
*/
|
||||
const seedFromLocalIndex = async (
|
||||
slot: string,
|
||||
repoPath: string,
|
||||
source: string,
|
||||
log: Log,
|
||||
): Promise<boolean> => {
|
||||
const sourceGraph = path.join(source, LBUG_DIRECTORY);
|
||||
const usable = (m: RepoMeta | null): m is RepoMeta =>
|
||||
!!m &&
|
||||
!m.incrementalInProgress &&
|
||||
!!m.lastCommit &&
|
||||
commitDistanceToHead(repoPath, m.lastCommit) !== null;
|
||||
if (!usable(await loadMeta(source)) || !(await exists(sourceGraph))) return false;
|
||||
let lock;
|
||||
try {
|
||||
lock = await acquireIndexLock(source, { timeoutMs: 2_000 });
|
||||
} catch {
|
||||
return false; // another analyze is writing it; seed from scratch instead
|
||||
}
|
||||
let meta: RepoMeta;
|
||||
try {
|
||||
if (lock.lockFree || (await inspectLbugSidecars(sourceGraph)).kind !== 'clean') return false;
|
||||
// Re-read under the lock: an analyze that finished while this waited
|
||||
// rewrote both, and the copied graph must match the saved metadata.
|
||||
const locked = await loadMeta(source);
|
||||
if (!usable(locked)) return false;
|
||||
meta = locked;
|
||||
await fs.mkdir(slot, { recursive: true });
|
||||
try {
|
||||
await cloneGraphFile(sourceGraph, path.join(slot, LBUG_DIRECTORY));
|
||||
} catch (err) {
|
||||
log(`Shared store: could not copy ${sourceGraph} (${(err as Error).message}).`);
|
||||
return false;
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
// A local index may hold uncommitted edits from when it was built. Clearing
|
||||
// lastCommit forces the next run through the file-hash diff, which rewrites
|
||||
// any file whose content differs, instead of trusting the up-to-date path.
|
||||
const seeded: RepoMeta = { ...meta, repoPath, storagePath: slot, lastCommit: '' };
|
||||
delete seeded.graphPath;
|
||||
await saveMeta(slot, seeded);
|
||||
log(`Shared store: seeded from the local index at ${source}.`);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Seed a slot that has no metadata so the run that follows is up to date or
|
||||
* incremental instead of a full build. Preference order:
|
||||
* 1. a pointer to the store's commit graph nearest to HEAD;
|
||||
* 2. a copy of this checkout's own repository-local index;
|
||||
* 3. a copy of the main checkout's repository-local index.
|
||||
* Caller holds the slot's index lock.
|
||||
*/
|
||||
export const seedSharedSlot = async (
|
||||
layout: SharedStoreLayout,
|
||||
repoPath: string,
|
||||
log: Log,
|
||||
): Promise<void> => {
|
||||
if (await loadMeta(layout.checkoutSlot)) return;
|
||||
const picked = pickSeed(repoPath, await listCommitGraphs(layout));
|
||||
const seed = picked?.graph;
|
||||
// A commit graph matches its commit exactly, so a checkout showing exactly
|
||||
// that commit is up to date. Any other checkout gets an empty lastCommit,
|
||||
// like seedFromLocalIndex, so its next run hash-diffs.
|
||||
const upToDate = picked?.distance === 0 && isWorkingTreePristine(repoPath);
|
||||
// Record the pointer under the publish lock, where reclaim counts
|
||||
// references, so the graph cannot be deleted between the pick and the save.
|
||||
const pointed =
|
||||
seed &&
|
||||
(await withStoreLock(layout, 'publish', async () => {
|
||||
const graph = path.join(seed.dir, LBUG_DIRECTORY);
|
||||
if (!(await exists(graph))) return false;
|
||||
await fs.mkdir(layout.checkoutSlot, { recursive: true });
|
||||
const meta: RepoMeta = {
|
||||
...seed.meta,
|
||||
repoPath,
|
||||
storagePath: layout.checkoutSlot,
|
||||
graphPath: graph,
|
||||
lastCommit: upToDate ? seed.meta.lastCommit : '',
|
||||
};
|
||||
delete meta.incrementalInProgress;
|
||||
await saveMeta(layout.checkoutSlot, meta);
|
||||
return true;
|
||||
}));
|
||||
if (seed && pointed) {
|
||||
log(`Shared store: seeded from commit graph ${seed.commit.slice(0, 12)}.`);
|
||||
return;
|
||||
}
|
||||
const locals = [repoPath, layout.canonicalCheckout]
|
||||
.filter((p): p is string => p !== null)
|
||||
.map((p) => path.join(p, GITNEXUS_DIR));
|
||||
for (const source of new Set(locals)) {
|
||||
if (await seedFromLocalIndex(layout.checkoutSlot, repoPath, source, log)) return;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Turn a pointer slot into a private one before analyze opens or writes the
|
||||
* graph. With `copy: false` the slot just stops pointing and the caller builds
|
||||
* its own graph from scratch. Returns false when the pointed-at shared graph cannot be copied
|
||||
* (garbage-collected or unreadable): the slot's file hashes then describe a
|
||||
* graph that is not there, and the caller must do a full build. Caller holds
|
||||
* the slot's index lock.
|
||||
*/
|
||||
export const ensurePrivateSharedGraph = async (
|
||||
slot: string,
|
||||
log: Log,
|
||||
opts: { copy?: boolean } = {},
|
||||
): Promise<boolean> => {
|
||||
const own = path.join(slot, LBUG_DIRECTORY);
|
||||
const pointed = resolveGraphPath(slot);
|
||||
if (pointed === own) return true;
|
||||
const meta = await loadMeta(slot);
|
||||
if (!meta) return true;
|
||||
// `copy: false` — the caller rebuilds from scratch and reads nothing from
|
||||
// the old graph, so only the pointer is dropped.
|
||||
if (opts.copy === false) {
|
||||
await fs.rm(path.join(slot, GRAPH_CLONE_MARKER), { force: true });
|
||||
} else if (!(await exists(own))) {
|
||||
const started = Date.now();
|
||||
try {
|
||||
await cloneGraphFile(pointed, own);
|
||||
} catch (err) {
|
||||
const reason = (err as NodeJS.ErrnoException).code ?? (err as Error).message;
|
||||
log(`Shared store: shared graph unavailable (${reason}); doing a full build.`);
|
||||
return false;
|
||||
}
|
||||
log(`Shared store: copied the shared graph for local changes in ${Date.now() - started}ms.`);
|
||||
}
|
||||
delete meta.graphPath;
|
||||
await saveMeta(slot, meta);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every directory in the store whose metadata may record parse-cache keys:
|
||||
* each checkout slot (its branch slots are read by the caller's per-root
|
||||
* fold) and each commit graph. `complete` is false when a store directory
|
||||
* exists but could not be listed: the caller must then keep every cached
|
||||
* chunk instead of pruning to a partial key set. A missing directory just
|
||||
* has no members, so the listing stays complete.
|
||||
*/
|
||||
export const listStoreMetaRoots = async (
|
||||
layout: SharedStoreLayout,
|
||||
): Promise<{ roots: string[]; complete: boolean }> => {
|
||||
const roots: string[] = [];
|
||||
let complete = true;
|
||||
for (const dir of [layout.checkoutsDir, layout.commitsDir]) {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(dir);
|
||||
} catch (err) {
|
||||
if (!isMissingFilesystemError(err)) complete = false;
|
||||
continue;
|
||||
}
|
||||
for (const name of names) {
|
||||
if (!name.startsWith('.')) roots.push(path.join(dir, name));
|
||||
}
|
||||
}
|
||||
return { roots, complete };
|
||||
};
|
||||
|
||||
/**
|
||||
* After a successful analyze of the flat slot: publish or reuse the commit
|
||||
* graph when the checkout is clean at HEAD, and make sure the registry points
|
||||
* at the slot. Caller holds the slot's index lock.
|
||||
*/
|
||||
export const publishSharedGraph = async (
|
||||
layout: SharedStoreLayout,
|
||||
repoPath: string,
|
||||
currentCommit: string,
|
||||
log: Log,
|
||||
): Promise<void> => {
|
||||
const slot = layout.checkoutSlot;
|
||||
const meta = await loadMeta(slot);
|
||||
if (!meta) return;
|
||||
const own = path.join(slot, LBUG_DIRECTORY);
|
||||
|
||||
// A graph built while files were dirty still holds those edits even after
|
||||
// they are reverted (the up-to-date path does not re-diff a clean tree), so
|
||||
// only a graph whose build saw no dirty covered file may become shared.
|
||||
const builtClean = (meta.indexCoverage?.dirtyPaths ?? []).length === 0;
|
||||
const shareable =
|
||||
currentCommit !== '' &&
|
||||
meta.lastCommit === currentCommit &&
|
||||
!meta.incrementalInProgress &&
|
||||
builtClean &&
|
||||
// Embeddings still owed stay with this checkout, which finishes them; a
|
||||
// commit graph never changes, so a shared copy would stay short for good.
|
||||
!meta.embeddingCheckpoint &&
|
||||
// A sparse or partial checkout builds a graph missing the files it hides.
|
||||
// Every sparse mode marks those entries skip-worktree, which this rejects
|
||||
// (git-utils.test.ts covers no-cone, cone and sparse-index checkouts).
|
||||
isWorkingTreePristine(repoPath);
|
||||
// Every pointer change and the reclaim that follows run under one publish
|
||||
// lock, so a concurrent reclaim never sees a half-recorded reference.
|
||||
await withStoreLock(layout, 'publish', async () => {
|
||||
let keptStaging = false;
|
||||
if (shareable) {
|
||||
const target = commitGraphDir(layout, currentCommit, featureKeyOf(meta));
|
||||
const targetGraph = path.join(target, LBUG_DIRECTORY);
|
||||
let published = await exists(targetGraph);
|
||||
// A commit graph published before embedding checkpoints were kept
|
||||
// private may hold fewer embeddings than this checkout's own graph.
|
||||
// Keep the better private graph; the commit graph stays as it is, since
|
||||
// other checkouts may read it.
|
||||
const targetMeta = published ? await loadMeta(target) : null;
|
||||
const ownIsBetter =
|
||||
published &&
|
||||
(!targetMeta ||
|
||||
!!targetMeta.embeddingCheckpoint ||
|
||||
(targetMeta.stats?.embeddings ?? 0) < (meta.stats?.embeddings ?? 0)) &&
|
||||
(await exists(own));
|
||||
if (ownIsBetter) {
|
||||
published = false;
|
||||
log(
|
||||
`Shared store: commit graph ${currentCommit.slice(0, 12)} is less complete; keeping the private graph.`,
|
||||
);
|
||||
} else if (published) {
|
||||
try {
|
||||
await wipeLbugDbFiles(own);
|
||||
} catch (err) {
|
||||
// An open reader (Windows) can block the delete. The analysis
|
||||
// already succeeded; keep the private graph and try next run.
|
||||
published = false;
|
||||
log(
|
||||
`Shared store: could not drop the private graph (${(err as Error).message}); keeping it.`,
|
||||
);
|
||||
}
|
||||
} else if ((await exists(own)) && (await inspectLbugSidecars(own)).kind === 'clean') {
|
||||
await fs.mkdir(layout.commitsDir, { recursive: true });
|
||||
const staging = path.join(layout.commitsDir, `.publish-${randomUUID()}`);
|
||||
await fs.mkdir(staging);
|
||||
const commitMeta: Record<string, unknown> = { ...meta };
|
||||
for (const field of CHECKOUT_FIELDS) delete commitMeta[field];
|
||||
try {
|
||||
await fs.rename(own, path.join(staging, LBUG_DIRECTORY));
|
||||
await fs.writeFile(path.join(staging, INDEX_METADATA_FILE), JSON.stringify(commitMeta));
|
||||
await fs.rename(staging, target);
|
||||
published = true;
|
||||
log(`Shared store: published commit graph ${currentCommit.slice(0, 12)}.`);
|
||||
} catch (err) {
|
||||
// Put the graph back so the slot stays usable as a private index.
|
||||
const staged = path.join(staging, LBUG_DIRECTORY);
|
||||
const restored = await fs.rename(staged, own).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (restored || !(await exists(staged))) {
|
||||
await fs.rm(staging, { recursive: true, force: true }).catch(() => {});
|
||||
log(
|
||||
`Shared store: could not publish (${(err as Error).message}); keeping a private graph.`,
|
||||
);
|
||||
} else {
|
||||
// The staging dir now holds this checkout's only graph. Keep it,
|
||||
// and skip this run's reclaim (which deletes unreferenced staging),
|
||||
// so it can be moved back by hand; the next analyze of this
|
||||
// checkout finds no graph and rebuilds.
|
||||
keptStaging = true;
|
||||
log(
|
||||
`Shared store: could not publish (${(err as Error).message}) or restore the graph; ` +
|
||||
`it is at ${staged}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (published) {
|
||||
meta.graphPath = targetGraph;
|
||||
await saveMeta(slot, meta);
|
||||
}
|
||||
} else if (meta.graphPath !== undefined && (await exists(own))) {
|
||||
delete meta.graphPath;
|
||||
await saveMeta(slot, meta);
|
||||
}
|
||||
// Reclaim would delete the kept staging dir as unreferenced.
|
||||
if (keptStaging) return;
|
||||
// Best effort: an unreadable store must not fail a finished analysis.
|
||||
try {
|
||||
const reclaimed = await reclaimSharedStoreLocked(layout.root);
|
||||
if (reclaimed.removed.length > 0) {
|
||||
log(`Shared store: removed ${reclaimed.removed.length} commit graph(s) no checkout uses.`);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Shared store: skipped cleanup (${(err as Error).message}).`);
|
||||
}
|
||||
});
|
||||
|
||||
// The up-to-date fast path skips registration; a seeded or adopted checkout
|
||||
// must still end up registered at its slot. Branch summaries recorded for a
|
||||
// previous storage location point at sub-indexes the slot does not hold.
|
||||
const previous = findRegistryEntryByRepoPath(await readRegistry(), repoPath);
|
||||
const moved =
|
||||
previous !== undefined &&
|
||||
!registryPathEquals(canonicalizePath(previous.storagePath), canonicalizePath(slot));
|
||||
await registerRepo(repoPath, meta, { storagePath: slot, dropBranches: moved });
|
||||
await writeSharedStorePointer(repoPath, layout);
|
||||
};
|
||||
|
||||
/** The store a checkout's registry entry already points into, if any. */
|
||||
const registeredStore = (
|
||||
entries: readonly RegistryEntry[],
|
||||
repoPath: string,
|
||||
): SharedStoreLayout | undefined => {
|
||||
const own = findRegistryEntryByRepoPath(entries, repoPath);
|
||||
const root = own ? storeRootOfCheckoutSlot(own.storagePath) : null;
|
||||
return root ? sharedStoreLayout(path.basename(root), repoPath) : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Store for a clone with no store of its own: the store a registered sibling
|
||||
* clone (same normalized `origin` URL, checkout still present) already uses,
|
||||
* or, when siblings exist but none shares yet, a new store keyed on the
|
||||
* canonical path that sorts first among this clone and its siblings. Every
|
||||
* sibling computes that same founder key, so clones founding the store
|
||||
* concurrently still land in one store (#3374); the key is only a name, so the
|
||||
* store keeps working if the founder's checkout is later deleted. Graphs are
|
||||
* keyed by commit and feature key, so clones only ever share a graph built
|
||||
* from the same commit with the same settings. A lone clone keeps its
|
||||
* repository-local index.
|
||||
*/
|
||||
const siblingCloneStore = (
|
||||
entries: readonly RegistryEntry[],
|
||||
repoPath: string,
|
||||
): SharedStoreLayout | undefined => {
|
||||
const remote = getRemoteUrl(repoPath);
|
||||
if (!remote) return undefined;
|
||||
const self = canonicalizePath(repoPath);
|
||||
const siblings = entries.filter(
|
||||
(e) =>
|
||||
e.remoteUrl === remote &&
|
||||
!registryPathEquals(canonicalizePath(e.path), self) &&
|
||||
existsSync(e.path),
|
||||
);
|
||||
if (siblings.length === 0) return undefined;
|
||||
const keys = siblings
|
||||
.map((e) => storeRootOfCheckoutSlot(e.storagePath))
|
||||
.filter((root): root is string => root !== null)
|
||||
.map((root) => path.basename(root))
|
||||
.sort();
|
||||
if (keys[0]) return sharedStoreLayout(keys[0], repoPath);
|
||||
// Order the way the registry compares paths: case-folded on Windows, whose
|
||||
// slot names hash the folded form too.
|
||||
const fold = (p: string): string => (registryPathEquals('A', 'a') ? p.toLowerCase() : p);
|
||||
const founder = [self, ...siblings.map((e) => canonicalizePath(e.path))].reduce((a, b) =>
|
||||
fold(b) < fold(a) ? b : a,
|
||||
);
|
||||
return sharedStoreLayout(cloneStoreKey(founder), repoPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Store for a checkout that is not a linked worktree: the store named by
|
||||
* `--share-with`, the one its registry entry already points into, or a
|
||||
* sibling clone's store (#3352). `--share-with` requires the normalized remote
|
||||
* URL to match the member it names (R3); `analyze --no-share` records an
|
||||
* opt-out that stops automatic joining. Only tree roots participate, as in
|
||||
* `resolveSharedStore`: `getRemoteUrl` answers from any subdirectory, so
|
||||
* without this gate `analyze --skip-git <clone>/pkg` would join (#3374).
|
||||
*/
|
||||
export const resolveOptedInStore = async (
|
||||
repoPath: string,
|
||||
shareWith: string | undefined,
|
||||
): Promise<SharedStoreLayout | undefined> => {
|
||||
if (!hasGitDir(repoPath)) {
|
||||
if (!shareWith) return undefined;
|
||||
throw new Error(
|
||||
`--share-with: "${repoPath}" is not the root of a git checkout. ` +
|
||||
'Only a clone root can share an index store.',
|
||||
);
|
||||
}
|
||||
const entries = await readRegistry();
|
||||
if (shareWith) {
|
||||
let target;
|
||||
try {
|
||||
target = resolveRegistryEntry(entries, shareWith);
|
||||
} catch {
|
||||
throw new Error(`--share-with: "${shareWith}" is not a registered repository.`);
|
||||
}
|
||||
const root = storeRootOfCheckoutSlot(target.storagePath);
|
||||
if (!root) {
|
||||
throw new Error(
|
||||
`--share-with: "${shareWith}" does not use a shared index store. ` +
|
||||
'Name a linked worktree of the repository (analyze it first).',
|
||||
);
|
||||
}
|
||||
const remote = getRemoteUrl(repoPath);
|
||||
if (!remote || remote !== target.remoteUrl) {
|
||||
throw new Error(
|
||||
`--share-with: remote URL mismatch — this checkout is "${remote ?? '(no origin remote)'}", ` +
|
||||
`"${target.name}" is "${target.remoteUrl ?? '(no origin remote)'}". ` +
|
||||
'Only clones of the same repository can share an index store.',
|
||||
);
|
||||
}
|
||||
return sharedStoreLayout(path.basename(root), repoPath);
|
||||
}
|
||||
const registered = registeredStore(entries, repoPath);
|
||||
if (registered) return registered;
|
||||
if (findRegistryEntryByRepoPath(entries, repoPath)?.shareOptOut) return undefined;
|
||||
return siblingCloneStore(entries, repoPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* The store slot a checkout is registered at, for `--no-share`. Linked
|
||||
* worktrees always share (turn sharing off with GITNEXUS_SHARED_STORE=off),
|
||||
* so only a clone can leave.
|
||||
*/
|
||||
export const optedInSlotToLeave = async (repoPath: string): Promise<string | undefined> => {
|
||||
if (resolveSharedStore(repoPath)) {
|
||||
throw new Error(
|
||||
'--no-share: linked worktrees always use the shared index store. ' +
|
||||
'Set GITNEXUS_SHARED_STORE=off to index every checkout into its own .gitnexus.',
|
||||
);
|
||||
}
|
||||
return registeredStore(await readRegistry(), repoPath)?.checkoutSlot;
|
||||
};
|
||||
|
||||
/**
|
||||
* After a successful `--no-share` run: re-register the checkout at its new
|
||||
* storage and delete its old store slot, both under the old slot's index lock
|
||||
* so an analyze still running on that slot cannot re-register it afterwards
|
||||
* or write into a deleted directory. Then reclaim what only that slot used.
|
||||
*/
|
||||
export const leaveSharedStore = async (
|
||||
repoPath: string,
|
||||
previousSlot: string,
|
||||
newStoragePath: string,
|
||||
log: Log,
|
||||
): Promise<void> => {
|
||||
const lock = await acquireIndexLock(previousSlot);
|
||||
try {
|
||||
requireExclusiveIndexLock(lock, `Cannot acquire the index lock at ${previousSlot}.`);
|
||||
await registerLeftStore(repoPath, newStoragePath);
|
||||
await removeSharedStorePointer(repoPath);
|
||||
// Last: the file lock backend keeps its lock file inside this directory.
|
||||
await fs.rm(previousSlot, { recursive: true, force: true });
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await reclaimAfterSlotRemoval(previousSlot);
|
||||
log(`Shared store: left ${previousSlot}.`);
|
||||
};
|
||||
|
||||
/**
|
||||
* After a run that indexed outside a store: if the registry still names a
|
||||
* store slot for this checkout, re-register it at `storagePath`. No-op when
|
||||
* the entry is already elsewhere or the new location has no finished index.
|
||||
*/
|
||||
export const registerLeftStore = async (repoPath: string, storagePath: string): Promise<void> => {
|
||||
const entry = findRegistryEntryByRepoPath(await readRegistry(), repoPath);
|
||||
if (!entry || !storeRootOfCheckoutSlot(entry.storagePath)) return;
|
||||
const meta = await loadMeta(storagePath);
|
||||
if (!meta?.lastCommit) return;
|
||||
await registerRepo(repoPath, meta, { storagePath });
|
||||
await removeSharedStorePointer(repoPath);
|
||||
};
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
* LadybugDB connections are opened lazily per repo on first query.
|
||||
*/
|
||||
|
||||
import { resolveGraphPath } from '../../storage/shared-store.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
|
|
@ -1902,7 +1903,7 @@ export class LocalBackend {
|
|||
const id = this.assignRepoId(entry.name, entry.path, resolved, assigned);
|
||||
|
||||
const storagePath = entry.storagePath;
|
||||
const lbugPath = path.join(storagePath, 'lbug');
|
||||
const lbugPath = resolveGraphPath(storagePath);
|
||||
|
||||
const handle: RepoHandle = {
|
||||
id,
|
||||
|
|
@ -2110,7 +2111,7 @@ export class LocalBackend {
|
|||
this.maybeWarnSiblingDrift(result).catch(() => {
|
||||
/* best-effort; never throw from resolveRepo */
|
||||
});
|
||||
return this.applyBranchScope(result, branch);
|
||||
return this.applyBranchScope(this.followSharedGraph(result), branch);
|
||||
}
|
||||
|
||||
// Still no match — throw with helpful message
|
||||
|
|
@ -2165,6 +2166,21 @@ export class LocalBackend {
|
|||
* and restamped labels the cached handle predates resolve on the next
|
||||
* call.
|
||||
*/
|
||||
/**
|
||||
* A shared-store checkout (#3352) moves between immutable commit graphs and
|
||||
* its private graph as it is re-analyzed, while the cached handle keeps the
|
||||
* graph it resolved first. Re-resolve the flat graph (one stat when the slot
|
||||
* metadata is unchanged) and update the cached handle when it moved, so the
|
||||
* pool opens the graph the checkout reads now.
|
||||
*/
|
||||
private followSharedGraph(handle: RepoHandle): RepoHandle {
|
||||
const current = resolveGraphPath(handle.storagePath);
|
||||
if (current === handle.lbugPath) return handle;
|
||||
const moved = { ...handle, lbugPath: current };
|
||||
if (this.repos.get(handle.id) === handle) this.repos.set(handle.id, moved);
|
||||
return moved;
|
||||
}
|
||||
|
||||
private async applyBranchScope(handle: RepoHandle, branch?: string): Promise<RepoHandle> {
|
||||
if (!branch) return handle;
|
||||
// At most one cache refresh per resolution: enough for the NEXT call to
|
||||
|
|
@ -2179,7 +2195,10 @@ export class LocalBackend {
|
|||
// One small JSON read per scoped call; mid-run meta writes preserve the
|
||||
// old label until the end-of-run atomic stamp (run-analyze dirty stamps
|
||||
// spread the existing meta), so this read never runs ahead of the DB.
|
||||
const flatMeta = await loadMeta(path.dirname(handle.lbugPath));
|
||||
// The flat slot's own metadata, not the graph's directory: a shared-store
|
||||
// checkout's graph sits in a commit directory whose metadata carries no
|
||||
// branch label (#3352).
|
||||
const flatMeta = await loadMeta(handle.storagePath);
|
||||
if (flatMeta?.branch && flatMeta.branch === branch) {
|
||||
// The disk meta decides routing, so it also supplies the metadata —
|
||||
// the cached handle's label/commit/stats can predate the restamp.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
* path is resolved relative to `import.meta.url`.
|
||||
*/
|
||||
|
||||
import { resolveGraphPath, storeRootOfCheckoutSlot } from '../storage/shared-store.js';
|
||||
import path from 'path';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { fork } from 'child_process';
|
||||
|
|
@ -141,23 +142,33 @@ const waitForSettledIndex = async (
|
|||
return false;
|
||||
}
|
||||
|
||||
const lbugPath = path.resolve(probeRoot, LBUG_DIRECTORY);
|
||||
const lbugRel = path.relative(storageRoot, lbugPath);
|
||||
if (lbugRel.startsWith('..') || path.isAbsolute(lbugRel)) {
|
||||
return false;
|
||||
}
|
||||
const lbugStat = statSync(lbugPath);
|
||||
|
||||
const metaPath = path.resolve(probeRoot, INDEX_METADATA_FILE);
|
||||
const metaRel = path.relative(storageRoot, metaPath);
|
||||
if (metaRel.startsWith('..') || path.isAbsolute(metaRel)) {
|
||||
return false;
|
||||
}
|
||||
const metaStat = statSync(metaPath);
|
||||
if (metaStat.mtimeMs < jobStartMs) return false;
|
||||
|
||||
if (lbugStat.mtimeMs < jobStartMs || metaStat.mtimeMs < jobStartMs) {
|
||||
// A shared-store checkout slot (#3352) may point at an immutable commit
|
||||
// graph, published consolidated and never rewritten, instead of owning a
|
||||
// graph file. Fresh metadata naming an existing commit graph is settled.
|
||||
const storeRoot = probeRoot === storageRoot ? storeRootOfCheckoutSlot(storageRoot) : null;
|
||||
if (storeRoot) {
|
||||
const graph = path.resolve(resolveGraphPath(storageRoot));
|
||||
const graphRel = path.relative(path.join(storeRoot, 'commits'), graph);
|
||||
if (!graphRel.startsWith('..') && !path.isAbsolute(graphRel) && graphRel !== '') {
|
||||
return existsSync(graph);
|
||||
}
|
||||
}
|
||||
|
||||
const lbugPath = path.resolve(probeRoot, LBUG_DIRECTORY);
|
||||
const lbugRel = path.relative(storageRoot, lbugPath);
|
||||
if (lbugRel.startsWith('..') || path.isAbsolute(lbugRel)) {
|
||||
return false;
|
||||
}
|
||||
const lbugStat = statSync(lbugPath);
|
||||
if (lbugStat.mtimeMs < jobStartMs) return false;
|
||||
|
||||
return ['lbug.wal', 'lbug.shadow', 'lbug.wal.checkpoint'].every((name) => {
|
||||
const sidePath = path.resolve(probeRoot, name);
|
||||
|
|
@ -327,7 +338,12 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
|
|||
const settle = msg.result.alreadyUpToDate
|
||||
? Promise.resolve(true)
|
||||
: waitForSettledIndex(
|
||||
analyzeLockKey,
|
||||
// A checkout's first analyze may write a shared-store slot the
|
||||
// launcher's pre-run lookup could not see yet (#3352); the
|
||||
// worker reports it.
|
||||
(msg.result.storagePath && storeRootOfCheckoutSlot(msg.result.storagePath)
|
||||
? msg.result.storagePath
|
||||
: null) ?? analyzeLockKey,
|
||||
jobStartMs,
|
||||
opts.branch,
|
||||
msg.result.isPrimaryBranch,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export type AnalyzeResultIpc = Pick<
|
|||
| 'ftsSkipped'
|
||||
| 'graphWriteCollapsed'
|
||||
| 'isPrimaryBranch'
|
||||
| 'storagePath'
|
||||
>;
|
||||
|
||||
/**
|
||||
|
|
@ -84,5 +85,8 @@ export function projectAnalyzeResultForIpc(result: AnalyzeResult): AnalyzeResult
|
|||
// `branches/<slug>/` sub-slot — so its finalization gate watches the files
|
||||
// this job actually rewrote (#3199 review).
|
||||
isPrimaryBranch: result.isPrimaryBranch,
|
||||
// The storage the run wrote. A checkout's first analyze may create a
|
||||
// shared-store slot (#3352) the launcher's pre-run lookup could not see.
|
||||
storagePath: result.storagePath,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,18 @@
|
|||
* CORS is restricted to localhost, private/LAN networks, and the deployed site.
|
||||
*/
|
||||
|
||||
import {
|
||||
acquireIndexLock,
|
||||
IndexLockTimeoutError,
|
||||
requireExclusiveIndexLock,
|
||||
type IndexLockHandle,
|
||||
} from '../storage/index-lock.js';
|
||||
import { ensurePrivateSharedGraph } from '../core/shared-store-analyze.js';
|
||||
import { resolveGraphPath } from '../storage/shared-store.js';
|
||||
import {
|
||||
reclaimAfterSlotRemoval,
|
||||
removeCheckoutStorage,
|
||||
} from '../storage/shared-store-lifecycle.js';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
|
|
@ -917,7 +929,7 @@ export const handleQueryRequest = async (
|
|||
return;
|
||||
}
|
||||
if (respondIfAnalysisPending(entry, res)) return;
|
||||
const lbugPath = path.join(entry.storagePath, 'lbug');
|
||||
const lbugPath = resolveGraphPath(entry.storagePath);
|
||||
const { skipFts } = await loadFtsSession(entry.storagePath);
|
||||
const result = await withLbugDb(
|
||||
lbugPath,
|
||||
|
|
@ -1316,8 +1328,22 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
await closeLbug();
|
||||
} catch {}
|
||||
|
||||
// 1. Delete the .gitnexus index/storage directory
|
||||
await fs.rm(storagePath, { recursive: true, force: true }).catch(() => {});
|
||||
// 1. Delete the index storage and unregister, as `gitnexus remove`
|
||||
// does: for a shared-store slot the unregister and the checkout's
|
||||
// pointer removal run under the slot's index lock. An analyze holding
|
||||
// that lock is a conflict; any other failure propagates as a 500 with
|
||||
// the entry left registered, so the delete can be retried.
|
||||
const { unregisterRepo } = await import('../storage/repo-manager.js');
|
||||
try {
|
||||
await removeCheckoutStorage(storagePath, () => unregisterRepo(entry.path), entry.path);
|
||||
} catch (err) {
|
||||
if (!(err instanceof IndexLockTimeoutError)) throw err;
|
||||
res.status(409).json({
|
||||
error: `Repository "${entry.name}" is being analyzed; retry the delete when it finishes. ${err.message}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await reclaimAfterSlotRemoval(storagePath);
|
||||
|
||||
// 2. Delete the cloned repo dir if it lives under ~/.gitnexus/repos/.
|
||||
// getCloneDir now throws on names that are not filesystem-safe (e.g.
|
||||
|
|
@ -1355,11 +1381,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
await fs.rm(resolvedEntry, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
// 3. Unregister from the global registry
|
||||
const { unregisterRepo } = await import('../storage/repo-manager.js');
|
||||
await unregisterRepo(entry.path);
|
||||
|
||||
// 4. Reinitialize backend to reflect the removal
|
||||
// 3. Reinitialize backend to reflect the removal
|
||||
await backend.init().catch(() => {});
|
||||
|
||||
res.json({ deleted: entry.name });
|
||||
|
|
@ -1380,7 +1402,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
return;
|
||||
}
|
||||
if (respondIfAnalysisPending(entry, res)) return;
|
||||
const lbugPath = path.join(entry.storagePath, 'lbug');
|
||||
const lbugPath = resolveGraphPath(entry.storagePath);
|
||||
const includeContent = req.query.includeContent === 'true';
|
||||
const stream = req.query.stream === 'true';
|
||||
const { skipFts } = await loadFtsSession(entry.storagePath);
|
||||
|
|
@ -1473,7 +1495,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
return;
|
||||
}
|
||||
if (respondIfAnalysisPending(entry, res)) return;
|
||||
const lbugPath = path.join(entry.storagePath, 'lbug');
|
||||
const lbugPath = resolveGraphPath(entry.storagePath);
|
||||
const parsedLimit = Number(req.body.limit ?? 10);
|
||||
const { ftsDisabledReason, skipFts } = await loadFtsSession(entry.storagePath);
|
||||
const limit = Number.isFinite(parsedLimit)
|
||||
|
|
@ -1680,7 +1702,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
const repoRoot = path.resolve(entry.path);
|
||||
const { skipFts } = await loadFtsSession(entry.storagePath);
|
||||
|
||||
const lbugPath = path.join(entry.storagePath, 'lbug');
|
||||
const lbugPath = resolveGraphPath(entry.storagePath);
|
||||
const fileRows = await withLbugDb(
|
||||
lbugPath,
|
||||
() =>
|
||||
|
|
@ -2125,7 +2147,21 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
// Set inside withLbugDb, read after it closes (#2790).
|
||||
let partialRunError: string | undefined;
|
||||
let partialRunDetail: AnalyzeJobPartialOutcome | undefined;
|
||||
// The in-memory repo lock only serializes this server; a CLI analyze
|
||||
// in another process guards the slot with the index lock, so hold it
|
||||
// for the whole embedding write, released in the finally below.
|
||||
let slotLock: IndexLockHandle | undefined;
|
||||
try {
|
||||
slotLock = await acquireIndexLock(storagePath);
|
||||
requireExclusiveIndexLock(
|
||||
slotLock,
|
||||
`Cannot acquire the index lock at ${storagePath}; refusing an unlocked embedding run.`,
|
||||
);
|
||||
// Writes go to the slot's own graph; a shared-store checkout
|
||||
// reading an immutable commit graph (#3352) takes a private copy.
|
||||
if (!(await ensurePrivateSharedGraph(storagePath, () => {}))) {
|
||||
throw new Error('The shared graph this repository reads is gone. Re-run analyze.');
|
||||
}
|
||||
const lbugPath = path.join(storagePath, LBUG_DIRECTORY);
|
||||
const ftsSession = await loadFtsSession(storagePath);
|
||||
let embeddingMeta = ftsSession.meta;
|
||||
|
|
@ -2359,6 +2395,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
});
|
||||
}
|
||||
} finally {
|
||||
slotLock?.release();
|
||||
clearTimeout(embedTimeout);
|
||||
releaseRepoLock(repoLockPath);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,6 +121,35 @@ export const listWorkingTreeDirtyPaths = (repoPath: string): string[] | null =>
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* True when the working tree shows exactly the committed tree: nothing dirty
|
||||
* or untracked, no path hidden by skip-worktree or assume-unchanged (which is
|
||||
* how a sparse checkout leaves files out), and every gitlink checked out as a
|
||||
* submodule. `git status` stays clean in all three hidden cases. False on any
|
||||
* git failure.
|
||||
*/
|
||||
export const isWorkingTreePristine = (repoPath: string): boolean => {
|
||||
// Includes every skip-worktree and assume-unchanged path (listHiddenIndexPaths,
|
||||
// `git ls-files -v`), so the `--stage` pass below only has to find gitlinks.
|
||||
if (listWorkingTreeDirtyPaths(repoPath)?.length !== 0) return false;
|
||||
try {
|
||||
const out = execFileSync('git', ['ls-files', '--stage', '-z', '--'], {
|
||||
cwd: repoPath,
|
||||
windowsHide: true,
|
||||
...gitPathListExec,
|
||||
});
|
||||
for (const record of out.split('\0')) {
|
||||
// `<mode> <object> <stage>\t<path>`; mode 160000 is a gitlink.
|
||||
if (!record.startsWith('160000 ')) continue;
|
||||
const rel = record.slice(record.indexOf('\t') + 1);
|
||||
if (!existsSync(path.join(repoPath, rel, '.git'))) return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Snapshot, per candidate file, whether it is safe for `selfCommitContextFiles`
|
||||
* to auto-commit — call this BEFORE `analyze` writes AGENTS.md/CLAUDE.md.
|
||||
|
|
@ -251,6 +280,31 @@ export const isGitRepo = (repoPath: string): boolean => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Number of commits from `ancestor` to HEAD, or null when `ancestor` is not
|
||||
* an ancestor of HEAD (or git fails). 0 means `ancestor` is HEAD.
|
||||
*/
|
||||
export const commitDistanceToHead = (repoPath: string, ancestor: string): number | null => {
|
||||
try {
|
||||
execFileSync('git', ['merge-base', '--is-ancestor', ancestor, 'HEAD'], {
|
||||
cwd: repoPath,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
const count = Number(
|
||||
execFileSync('git', ['rev-list', '--count', `${ancestor}..HEAD`], {
|
||||
cwd: repoPath,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
encoding: 'utf8',
|
||||
}).trim(),
|
||||
);
|
||||
return Number.isInteger(count) ? count : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCurrentCommit = (repoPath: string): string => {
|
||||
try {
|
||||
return execSync('git rev-parse HEAD', {
|
||||
|
|
|
|||
|
|
@ -152,6 +152,11 @@ export interface AcquireOptions {
|
|||
pollMs?: number;
|
||||
/** Called once when we start waiting on a live holder. */
|
||||
onWaitStart?: (holder: LockRecord) => void;
|
||||
/**
|
||||
* Sweep orphaned staging files once the lock is held. Default true; pass
|
||||
* false for a read-only caller, such as a dry run, that must delete nothing.
|
||||
*/
|
||||
sweep?: boolean;
|
||||
}
|
||||
|
||||
export class IndexLockTimeoutError extends Error {
|
||||
|
|
@ -907,7 +912,7 @@ export const acquireIndexLock = async (
|
|||
}
|
||||
// Without ownership, a staging file may belong to an active writer.
|
||||
try {
|
||||
if (!handle.lockFree) sweepStagingArtifacts(lockDir, opts.log);
|
||||
if (!handle.lockFree && opts.sweep !== false) sweepStagingArtifacts(lockDir, opts.log);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import {
|
|||
type RepoMeta,
|
||||
} from './repo-meta.js';
|
||||
import { LBUG_DIRECTORY } from './storage-constants.js';
|
||||
import { resolveGraphPath } from './shared-store.js';
|
||||
import {
|
||||
defaultStoragePath,
|
||||
ensureStoragePathWritable,
|
||||
|
|
@ -190,6 +191,11 @@ export interface RegistryEntry {
|
|||
* legacy registry shape.
|
||||
*/
|
||||
branches?: BranchSummary[];
|
||||
/**
|
||||
* The checkout left sharing with `analyze --no-share` (#3352), so it does
|
||||
* not join a sibling clone's store automatically. Cleared by `--share-with`.
|
||||
*/
|
||||
shareOptOut?: true;
|
||||
}
|
||||
|
||||
/** Path-only registry lookup. Canonicalizes `repoPath` once. Does not throw. */
|
||||
|
|
@ -213,9 +219,11 @@ const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`;
|
|||
* across branches (#2106 KTD7). When `branch` is provided, both `lbugPath`
|
||||
* and `metaPath` are scoped under `branches/<slug>/`. For the flat call
|
||||
* (no `branch`), `storagePath` and `lbugPath` remain byte-identical to the
|
||||
* pre-multi-branch behavior (#2106); `metaPath`'s FILENAME changed from
|
||||
* `meta.json` to `gitnexus.json` (PR #2363) — `saveMeta` keeps a `meta.json`
|
||||
* mirror in sync for consumers that still read the legacy name.
|
||||
* pre-multi-branch behavior (#2106), except that a shared-store checkout slot
|
||||
* (#3352) returns the commit graph its metadata records (`resolveGraphPath`).
|
||||
* `metaPath`'s FILENAME changed from `meta.json` to `gitnexus.json`
|
||||
* (PR #2363) — `saveMeta` keeps a `meta.json` mirror in sync for consumers
|
||||
* that still read the legacy name.
|
||||
*
|
||||
* Each branch slot has its own metadata file:
|
||||
* - Primary/flat: <repo>/.gitnexus/gitnexus.json
|
||||
|
|
@ -234,7 +242,9 @@ export const getStoragePaths = (
|
|||
const baseDir = branch ? path.join(storagePath, BRANCHES_DIR, branchSlug(branch)) : storagePath;
|
||||
return {
|
||||
storagePath,
|
||||
lbugPath: path.join(baseDir, LBUG_DIRECTORY),
|
||||
// Branch slots are always private; a flat shared-store slot may read a
|
||||
// commit graph (#3352).
|
||||
lbugPath: branch ? path.join(baseDir, LBUG_DIRECTORY) : resolveGraphPath(storagePath),
|
||||
metaPath: path.join(baseDir, INDEX_METADATA_FILE), // Branch-specific metadata file
|
||||
};
|
||||
};
|
||||
|
|
@ -887,6 +897,12 @@ export interface RegisterRepoOptions {
|
|||
* analysis or index operation has begun.
|
||||
*/
|
||||
storagePath?: string;
|
||||
/**
|
||||
* Drop recorded `branches[]` summaries on a primary run. Set when the entry
|
||||
* moves to a different storage location (a shared-store slot, #3352): the
|
||||
* summaries name `branches/<slug>` sub-indexes the new location does not hold.
|
||||
*/
|
||||
dropBranches?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1163,8 +1179,9 @@ const registerRepoUnlocked = async (
|
|||
// Primary run: apply our refreshed top-level, but defer to the FRESH
|
||||
// branches[] (a concurrent branch upsert or `clean --branch` wins).
|
||||
merged = { ...entry };
|
||||
if (freshExisting?.branches) merged.branches = freshExisting.branches;
|
||||
if (freshExisting?.branches && !opts?.dropBranches) merged.branches = freshExisting.branches;
|
||||
else delete merged.branches;
|
||||
if (freshExisting?.shareOptOut) merged.shareOptOut = true;
|
||||
}
|
||||
if (freshIdx >= 0) {
|
||||
fresh[freshIdx] = merged;
|
||||
|
|
@ -1223,6 +1240,20 @@ const unregisterRepoUnlocked = async (repoPath: string): Promise<void> => {
|
|||
export const unregisterRepo = async (repoPath: string): Promise<void> =>
|
||||
withRegistryLock(() => unregisterRepoUnlocked(repoPath));
|
||||
|
||||
/**
|
||||
* Record (or clear) a checkout's opt-out from automatic clone sharing
|
||||
* (#3352). A no-op when the checkout is not registered.
|
||||
*/
|
||||
export const setShareOptOut = async (repoPath: string, optOut: boolean): Promise<void> =>
|
||||
withRegistryLock(async () => {
|
||||
const entries = await readRegistryStrict();
|
||||
const entry = findRegistryEntryByRepoPath(entries, repoPath);
|
||||
if (!entry || !!entry.shareOptOut === optOut) return;
|
||||
if (optOut) entry.shareOptOut = true;
|
||||
else delete entry.shareOptOut;
|
||||
await writeRegistry(entries);
|
||||
});
|
||||
|
||||
/**
|
||||
* Remove a single non-primary branch's summary from a repo's registry entry
|
||||
* (#2106 R7). Called by `gitnexus clean --branch`. Returns `true` when a
|
||||
|
|
|
|||
|
|
@ -102,6 +102,12 @@ export interface RepoMeta {
|
|||
repoPath: string;
|
||||
/** Complete index directory selected for this successful analysis. */
|
||||
storagePath?: string;
|
||||
/**
|
||||
* Shared-store checkouts only (#3352): the graph this slot reads when it is
|
||||
* not `<storagePath>/lbug` — a commit graph under the same store. Resolved
|
||||
* and validated by `resolveGraphPath`.
|
||||
*/
|
||||
graphPath?: string;
|
||||
lastCommit: string;
|
||||
indexedAt: string;
|
||||
/** Missing on legacy metadata means the upstream-compatible `full` profile. */
|
||||
|
|
|
|||
458
gitnexus/src/storage/shared-store-lifecycle.ts
Normal file
458
gitnexus/src/storage/shared-store-lifecycle.ts
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
/**
|
||||
* Store-wide locking and reference-counted cleanup for the shared sibling
|
||||
* store (#3352).
|
||||
*
|
||||
* A commit graph is live while any member checkout slot's metadata records
|
||||
* it as `graphPath`. `reclaimSharedStore` deletes every commit graph with no
|
||||
* reference, and the whole store once no member and no commit graph remain.
|
||||
* It runs under the store's publish lock, so a concurrent analyze that is
|
||||
* publishing or pointing at a graph is never raced.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { acquireIndexLock, requireExclusiveIndexLock, type IndexLockHandle } from './index-lock.js';
|
||||
import {
|
||||
canonicalizePath,
|
||||
findRegistryEntryByRepoPath,
|
||||
readRegistryStrictIfPresent,
|
||||
registryPathEquals,
|
||||
} from './repo-manager.js';
|
||||
import { isMissingFilesystemError, loadMeta, type RepoMeta } from './repo-meta.js';
|
||||
import {
|
||||
SHARED_STORE_POINTER,
|
||||
storeRootOfCheckoutSlot,
|
||||
type SharedStoreLayout,
|
||||
} from './shared-store.js';
|
||||
import {
|
||||
GITNEXUS_DIR,
|
||||
INDEX_METADATA_FILE,
|
||||
LBUG_DIRECTORY,
|
||||
LEGACY_METADATA_FILE,
|
||||
} from './storage-constants.js';
|
||||
|
||||
type StoreRoot = Pick<SharedStoreLayout, 'root'>;
|
||||
|
||||
/**
|
||||
* Serialize one kind of store-wide write (`publish`, `cache`) across
|
||||
* checkouts. Each checkout's own slot is already covered by its index lock.
|
||||
*/
|
||||
export const withStoreLock = async <T>(
|
||||
layout: StoreRoot,
|
||||
name: 'publish' | 'cache',
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const lockDir = path.join(layout.root, 'locks', name);
|
||||
await fs.mkdir(lockDir, { recursive: true });
|
||||
const lock = await acquireIndexLock(lockDir);
|
||||
try {
|
||||
requireExclusiveIndexLock(lock, `Cannot acquire the shared-store ${name} lock at ${lockDir}.`);
|
||||
return await fn();
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
};
|
||||
|
||||
export interface ReclaimResult {
|
||||
/** Commit graph directories deleted. */
|
||||
removed: string[];
|
||||
/** Unreferenced commit graphs that could not be deleted (for example, open on Windows). */
|
||||
kept: string[];
|
||||
/** Member slots dropped by garbage collection. */
|
||||
droppedMembers: string[];
|
||||
/** Orphaned member slots garbage collection could not delete; still counted as members. */
|
||||
keptMembers: string[];
|
||||
/** The store root was deleted because nothing remained. */
|
||||
storeRemoved: boolean;
|
||||
}
|
||||
|
||||
const listDir = (dir: string): Promise<string[]> => fs.readdir(dir).catch(() => [] as string[]);
|
||||
|
||||
/**
|
||||
* Listing for reclaim decisions: only a missing directory is empty. Any other
|
||||
* read error aborts, because treating an unreadable `checkouts/` as "no
|
||||
* members" would delete every commit graph as unreferenced.
|
||||
*/
|
||||
const listDirStrict = (dir: string): Promise<string[]> =>
|
||||
fs.readdir(dir).catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'ENOENT') return [] as string[];
|
||||
throw err;
|
||||
});
|
||||
|
||||
/**
|
||||
* Slot metadata for reclaim decisions, read like `loadMeta` (the legacy
|
||||
* mirror only when `gitnexus.json` is absent). Only absent metadata means "no
|
||||
* reference"; an unreadable or unparseable file aborts, because `loadMeta`'s
|
||||
* null there would delete the commit graph the slot still points at.
|
||||
*/
|
||||
const loadMetaStrict = async (slot: string): Promise<RepoMeta | null> => {
|
||||
for (const file of [INDEX_METADATA_FILE, LEGACY_METADATA_FILE]) {
|
||||
const metaPath = path.join(slot, file);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(metaPath, 'utf-8');
|
||||
} catch (err) {
|
||||
if (isMissingFilesystemError(err)) continue;
|
||||
throw new Error(`Cannot read ${metaPath}: ${(err as Error).message}`, { cause: err });
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as RepoMeta;
|
||||
} catch (err) {
|
||||
throw new Error(`Cannot parse ${metaPath}: ${(err as Error).message}`, { cause: err });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Member slots that no registry entry uses any more: the checkout directory is
|
||||
* gone, or its entry moved elsewhere (`--no-share`, sharing turned off). The
|
||||
* registry is the membership record for opted-in clones and for a main
|
||||
* checkout whose last worktree was removed, so identity alone cannot decide.
|
||||
* A slot with no attributable `repoPath` is never collected. An unreadable
|
||||
* registry aborts; without a registry file only slots whose checkout is gone
|
||||
* are collected.
|
||||
*/
|
||||
const orphanMembers = async (slots: string[]): Promise<Set<string>> => {
|
||||
const entries = await readRegistryStrictIfPresent();
|
||||
const orphans = new Set<string>();
|
||||
for (const slot of slots) {
|
||||
const meta = await loadMeta(slot);
|
||||
if (!meta?.repoPath) continue;
|
||||
if (!existsSync(meta.repoPath)) {
|
||||
orphans.add(slot);
|
||||
continue;
|
||||
}
|
||||
if (!entries) continue;
|
||||
const entry = findRegistryEntryByRepoPath(entries, meta.repoPath);
|
||||
if (
|
||||
!entry ||
|
||||
!registryPathEquals(canonicalizePath(entry.storagePath), canonicalizePath(slot))
|
||||
) {
|
||||
orphans.add(slot);
|
||||
}
|
||||
}
|
||||
return orphans;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reclaim with the store's publish lock already held. Analyze calls this right
|
||||
* after publishing so graphs a checkout stopped using are deleted at once
|
||||
* (KTD7), and slot pointers written under the same lock are always counted.
|
||||
*/
|
||||
export const reclaimSharedStoreLocked = async (
|
||||
storeRootInput: string,
|
||||
opts: { gc?: boolean; dryRun?: boolean } = {},
|
||||
): Promise<ReclaimResult> => {
|
||||
// Absolute, so commit dirs compare equal to the resolved graphPath parents
|
||||
// even when GITNEXUS_HOME is relative.
|
||||
const storeRoot = path.resolve(storeRootInput);
|
||||
const result: ReclaimResult = {
|
||||
removed: [],
|
||||
kept: [],
|
||||
droppedMembers: [],
|
||||
keptMembers: [],
|
||||
storeRemoved: false,
|
||||
};
|
||||
const checkoutsDir = path.join(storeRoot, 'checkouts');
|
||||
const commitsDir = path.join(storeRoot, 'commits');
|
||||
const referenced = new Set<string>();
|
||||
let slots = (await listDirStrict(checkoutsDir)).map((name) => path.join(checkoutsDir, name));
|
||||
if (opts.gc) {
|
||||
const orphans = await orphanMembers(slots);
|
||||
for (const slot of [...orphans]) {
|
||||
// An analyze holds its slot's index lock until it has registered the
|
||||
// checkout, so a slot that is seeded but not yet registered is busy,
|
||||
// not orphaned. Judge (and, unless previewing, delete) only a slot
|
||||
// whose lock is free, so the preview matches what --force would do.
|
||||
let lock: IndexLockHandle;
|
||||
try {
|
||||
// A preview deletes nothing, not even the staging files the lock sweeps.
|
||||
lock = await acquireIndexLock(slot, { timeoutMs: 1, sweep: !opts.dryRun });
|
||||
} catch {
|
||||
orphans.delete(slot);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (lock.lockFree || !(await orphanMembers([slot])).has(slot)) {
|
||||
orphans.delete(slot);
|
||||
continue;
|
||||
}
|
||||
if (!opts.dryRun) {
|
||||
try {
|
||||
await fs.rm(slot, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Like an undeletable graph below: keep it for the next collection
|
||||
// rather than abort every store after this one. It stays a member,
|
||||
// so whatever graph it still names is kept too.
|
||||
orphans.delete(slot);
|
||||
result.keptMembers.push(slot);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.droppedMembers.push(slot);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
slots = slots.filter((slot) => !orphans.has(slot));
|
||||
}
|
||||
for (const slot of slots) {
|
||||
const graphPath = (await loadMetaStrict(slot))?.graphPath;
|
||||
if (graphPath) referenced.add(path.dirname(path.resolve(graphPath)));
|
||||
}
|
||||
|
||||
for (const name of await listDirStrict(commitsDir)) {
|
||||
const dir = path.join(commitsDir, name);
|
||||
if (referenced.has(dir)) continue;
|
||||
if (opts.dryRun) {
|
||||
if (!name.startsWith('.')) result.removed.push(dir);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
if (!name.startsWith('.')) result.removed.push(dir);
|
||||
} catch {
|
||||
// Windows refuses to delete a file another process has open (an MCP
|
||||
// reader). Keep it for the next reclaim instead of failing the caller.
|
||||
if (!name.startsWith('.')) result.kept.push(dir);
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = async (): Promise<boolean> =>
|
||||
(await listDirStrict(checkoutsDir)).length + (await listDirStrict(commitsDir)).length === 0;
|
||||
if (!opts.dryRun && (await isEmpty())) {
|
||||
// Also hold the cache lock (publish -> cache, the only nesting order) so
|
||||
// a member saving caches cannot lose them, then re-check: a new member's
|
||||
// slot may have appeared while waiting. The lock directories live inside
|
||||
// the store; removing them while held is safe on POSIX and is retried on
|
||||
// the next reclaim elsewhere.
|
||||
await withStoreLock({ root: storeRoot }, 'cache', async () => {
|
||||
if (!(await isEmpty())) return;
|
||||
await fs
|
||||
.rm(storeRoot, { recursive: true, force: true })
|
||||
.then(() => {
|
||||
result.storeRemoved = true;
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete unreferenced commit graphs, stale publish staging, and — with `gc` —
|
||||
* member slots no registry entry uses. Removes the store itself when nothing
|
||||
* remains.
|
||||
*/
|
||||
export const reclaimSharedStore = async (
|
||||
storeRoot: string,
|
||||
opts: { gc?: boolean; dryRun?: boolean } = {},
|
||||
): Promise<ReclaimResult> => {
|
||||
if (!existsSync(storeRoot)) {
|
||||
return { removed: [], kept: [], droppedMembers: [], keptMembers: [], storeRemoved: false };
|
||||
}
|
||||
return withStoreLock({ root: storeRoot }, 'publish', () =>
|
||||
reclaimSharedStoreLocked(storeRoot, opts),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* After a storage slot was deleted: reclaim its store when it was a shared
|
||||
* checkout slot. No-op for any other storage path. Never throws — the slot
|
||||
* deletion already succeeded and reclaim is retried by the next clean.
|
||||
*/
|
||||
export const reclaimAfterSlotRemoval = async (
|
||||
storagePath: string,
|
||||
): Promise<ReclaimResult | null> => {
|
||||
const storeRoot = storeRootOfCheckoutSlot(storagePath);
|
||||
if (!storeRoot) return null;
|
||||
try {
|
||||
return await reclaimSharedStore(storeRoot);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Records in a checkout slot how its private graph was copied from a shared one. */
|
||||
export const GRAPH_CLONE_MARKER = 'graph-clone';
|
||||
export type GraphCloneKind = 'copy-on-write' | 'copy';
|
||||
|
||||
/** How the slot's private graph was copied, or null when it was built, not copied. */
|
||||
export const readGraphCloneKind = async (storagePath: string): Promise<GraphCloneKind | null> => {
|
||||
const text = await fs
|
||||
.readFile(path.join(storagePath, GRAPH_CLONE_MARKER), 'utf-8')
|
||||
.catch(() => null);
|
||||
return text === 'copy-on-write' || text === 'copy' ? text : null;
|
||||
};
|
||||
|
||||
/** Whether a checkout slot reads a shared commit graph or its own private graph. */
|
||||
export const describeSharedGraph = (
|
||||
graphPath: string,
|
||||
storagePath: string,
|
||||
): 'shared' | 'private' =>
|
||||
path.resolve(graphPath) === path.join(path.resolve(storagePath), LBUG_DIRECTORY)
|
||||
? 'private'
|
||||
: 'shared';
|
||||
|
||||
/** Files a shared checkout keeps in `<checkout>/.gitnexus`; everything else there is legacy. */
|
||||
const POINTER_DIR_KEEP = new Set([SHARED_STORE_POINTER, '.gitignore', 'run.cjs']);
|
||||
|
||||
/**
|
||||
* Whether `<checkout>/.gitnexus` is absent, a real directory inside the
|
||||
* checkout, or anything else. A symlink (or junction) there could point
|
||||
* anywhere — `.gitnexus -> ..` would expose the checkout's parent — so
|
||||
* nothing is written, listed, or deleted through it.
|
||||
*/
|
||||
const probePointerDir = async (
|
||||
checkoutPath: string,
|
||||
): Promise<{ status: 'missing' } | { status: 'contained'; dir: string } | { status: 'unsafe' }> => {
|
||||
const dir = path.join(checkoutPath, GITNEXUS_DIR);
|
||||
let stat: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
try {
|
||||
stat = await fs.lstat(dir);
|
||||
} catch (err) {
|
||||
return isMissingFilesystemError(err) ? { status: 'missing' } : { status: 'unsafe' };
|
||||
}
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) return { status: 'unsafe' };
|
||||
try {
|
||||
const real = await fs.realpath(dir);
|
||||
const expected = path.join(await fs.realpath(checkoutPath), GITNEXUS_DIR);
|
||||
return path.relative(real, expected) === ''
|
||||
? { status: 'contained', dir }
|
||||
: { status: 'unsafe' };
|
||||
} catch {
|
||||
return { status: 'unsafe' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Point `<checkout>/.gitnexus` at the checkout's store slot (#3352 R16). The
|
||||
* directory's other contents — a pre-adoption index — are left untouched.
|
||||
*/
|
||||
export const writeSharedStorePointer = async (
|
||||
checkoutPath: string,
|
||||
layout: Pick<SharedStoreLayout, 'key' | 'checkoutSlot'>,
|
||||
): Promise<void> => {
|
||||
if ((await probePointerDir(checkoutPath)).status === 'missing') {
|
||||
await fs.mkdir(path.join(checkoutPath, GITNEXUS_DIR), { recursive: true });
|
||||
}
|
||||
const probe = await probePointerDir(checkoutPath);
|
||||
if (probe.status !== 'contained') return;
|
||||
const { dir } = probe;
|
||||
await fs.writeFile(
|
||||
path.join(dir, SHARED_STORE_POINTER),
|
||||
`${JSON.stringify({ version: 1, storeKey: layout.key, checkoutSlot: layout.checkoutSlot }, null, 2)}\n`,
|
||||
);
|
||||
await fs.writeFile(path.join(dir, '.gitignore'), '*\n', { flag: 'wx' }).catch(() => {});
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove the pointer file. The directory stays if it holds anything else, or
|
||||
* if it cannot be listed (its contents are then unknown).
|
||||
*/
|
||||
export const removeSharedStorePointer = async (checkoutPath: string): Promise<void> => {
|
||||
const probe = await probePointerDir(checkoutPath);
|
||||
if (probe.status !== 'contained') return;
|
||||
const { dir } = probe;
|
||||
await fs.rm(path.join(dir, SHARED_STORE_POINTER), { force: true });
|
||||
const rest = await fs
|
||||
.readdir(dir)
|
||||
.catch((err: NodeJS.ErrnoException) => (err.code === 'ENOENT' ? [] : null));
|
||||
if (rest?.every((name) => POINTER_DIR_KEEP.has(name))) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
const sizeOf = async (target: string): Promise<number> => {
|
||||
const stat = await fs.lstat(target).catch(() => null);
|
||||
if (!stat) return 0;
|
||||
if (!stat.isDirectory()) return stat.size;
|
||||
let total = 0;
|
||||
for (const name of await listDir(target)) total += await sizeOf(path.join(target, name));
|
||||
return total;
|
||||
};
|
||||
|
||||
export interface LegacyLocalIndex {
|
||||
dir: string;
|
||||
entries: string[];
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pre-adoption index left in `<checkout>/.gitnexus` after the checkout moved
|
||||
* into a shared store (#3352 R13). Null when the checkout is not shared or
|
||||
* the directory holds only the pointer.
|
||||
*/
|
||||
export const findLegacyLocalIndex = async (
|
||||
checkoutPath: string,
|
||||
storagePath: string,
|
||||
): Promise<LegacyLocalIndex | null> => {
|
||||
if (!storeRootOfCheckoutSlot(storagePath)) return null;
|
||||
const probe = await probePointerDir(checkoutPath);
|
||||
if (probe.status !== 'contained') return null;
|
||||
const { dir } = probe;
|
||||
const entries = (await listDir(dir)).filter((name) => !POINTER_DIR_KEEP.has(name));
|
||||
if (entries.length === 0) return null;
|
||||
let bytes = 0;
|
||||
for (const name of entries) bytes += await sizeOf(path.join(dir, name));
|
||||
return { dir, entries, bytes };
|
||||
};
|
||||
|
||||
/** Delete a legacy local index, keeping the pointer. Returns what was removed. */
|
||||
export const removeLegacyLocalIndex = async (
|
||||
checkoutPath: string,
|
||||
storagePath: string,
|
||||
): Promise<LegacyLocalIndex | null> => {
|
||||
const legacy = await findLegacyLocalIndex(checkoutPath, storagePath);
|
||||
if (!legacy) return null;
|
||||
// Sizing walked the whole index; re-check the directory was not swapped meanwhile.
|
||||
if ((await probePointerDir(checkoutPath)).status !== 'contained') return null;
|
||||
for (const name of legacy.entries) {
|
||||
await fs.rm(path.join(legacy.dir, name), { recursive: true, force: true });
|
||||
}
|
||||
return legacy;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a checkout's index storage and run `unregister`. For a shared-store
|
||||
* checkout slot this happens under the slot's index lock, which an analyze
|
||||
* holds until it has registered the checkout and written its pointer, so it
|
||||
* cannot re-register a removed slot, write into it, or have its new pointer
|
||||
* deleted. There `unregister` and removing `checkoutPath`'s pointer run
|
||||
* first and the slot directory goes last: the file lock backend keeps its
|
||||
* lock file inside that directory, so nothing may depend on the lock after
|
||||
* it is deleted. Other storage is deleted, then unregistered, as before.
|
||||
*/
|
||||
export const removeCheckoutStorage = async (
|
||||
storagePath: string,
|
||||
unregister: () => Promise<void> = async () => {},
|
||||
checkoutPath?: string,
|
||||
): Promise<void> => {
|
||||
if (!storeRootOfCheckoutSlot(storagePath)) {
|
||||
await fs.rm(storagePath, { recursive: true, force: true });
|
||||
await unregister();
|
||||
return;
|
||||
}
|
||||
const lock = await acquireIndexLock(storagePath);
|
||||
try {
|
||||
requireExclusiveIndexLock(lock, `Cannot acquire the index lock at ${storagePath}.`);
|
||||
await unregister();
|
||||
if (checkoutPath) await removeSharedStorePointer(checkoutPath);
|
||||
try {
|
||||
await fs.rm(storagePath, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// The checkout is already unregistered, so a plain `clean` can no longer
|
||||
// find this slot. It is now an orphan member, which `clean --gc` removes.
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`The checkout was unregistered, but its index storage at ${storagePath} could not be deleted (${reason}). ` +
|
||||
'Run `gitnexus clean --gc --force` to remove it.',
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
};
|
||||
323
gitnexus/src/storage/shared-store.ts
Normal file
323
gitnexus/src/storage/shared-store.ts
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
/**
|
||||
* Shared sibling index store (#3352).
|
||||
*
|
||||
* Linked worktrees of one repository share one store under the GitNexus home:
|
||||
*
|
||||
* <GITNEXUS_HOME>/stores/<key>/
|
||||
* caches/ parse-cache + durable ParsedFile store
|
||||
* commits/<commit>-<featureKey>/ one immutable graph per commit + settings
|
||||
* checkouts/<slot>/ one checkout's metadata, membership, and
|
||||
* private graph when it has local edits
|
||||
*
|
||||
* This module only resolves identity and names paths. It never creates,
|
||||
* writes, or deletes anything.
|
||||
*
|
||||
* Membership is decided from the `.git` entry alone (no `git` subprocess), so
|
||||
* the resolver stays cheap on hot paths (hooks, every CLI call). Only tree
|
||||
* roots participate — a subdirectory of a checkout never resolves to a store,
|
||||
* mirroring the `resolveRepoIdentityRoot` gate (#1259). A repository with no
|
||||
* linked worktree keeps its repository-local `.gitnexus`.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { stripWindowsLongPathPrefix } from '../lib/utils.js';
|
||||
import { getGlobalDir } from './global-dir.js';
|
||||
import { GITNEXUS_DIR, INDEX_METADATA_FILE, LBUG_DIRECTORY } from './storage-constants.js';
|
||||
import { slotNameForCanonicalPath, STORAGE_PATH_ENV, STORAGE_ROOT_ENV } from './storage-slot.js';
|
||||
|
||||
export const SHARED_STORE_ENV = 'GITNEXUS_SHARED_STORE';
|
||||
export const STORES_DIR = 'stores';
|
||||
/**
|
||||
* Written into `<checkout>/.gitnexus/` when a checkout's index lives in a
|
||||
* shared store, so tools that probe the checkout can find it (#3352 R16).
|
||||
*/
|
||||
export const SHARED_STORE_POINTER = 'store.json';
|
||||
|
||||
// Same canonical form as storage-resolver's `storageSlotName`, so a checkout's
|
||||
// slot name does not depend on which spelling (symlink, 8.3 name) reached it.
|
||||
const slotName = (p: string): string => {
|
||||
const resolved = path.resolve(p);
|
||||
let canonical: string;
|
||||
try {
|
||||
canonical = fs.realpathSync.native(resolved);
|
||||
} catch {
|
||||
canonical = resolved;
|
||||
}
|
||||
return slotNameForCanonicalPath(stripWindowsLongPathPrefix(canonical));
|
||||
};
|
||||
|
||||
const DISABLED_VALUES = new Set(['off', '0', 'false', 'no']);
|
||||
const COMMIT_RE = /^[0-9a-f]{7,64}$/;
|
||||
const FEATURE_KEY_RE = /^[0-9a-f]{8,64}$/;
|
||||
const COMMIT_GRAPH_DIR_RE = /^[0-9a-f]{7,64}-[0-9a-f]{8,64}$/;
|
||||
|
||||
export interface SharedStoreLayout {
|
||||
/** Store key: readable basename plus a hash of the canonical git common dir. */
|
||||
key: string;
|
||||
root: string;
|
||||
cachesDir: string;
|
||||
commitsDir: string;
|
||||
checkoutsDir: string;
|
||||
/** This checkout's slot — the registry `storagePath` for a shared checkout. */
|
||||
checkoutSlot: string;
|
||||
/** The main checkout (parent of the git common dir); null for a bare repository. */
|
||||
canonicalCheckout: string | null;
|
||||
}
|
||||
|
||||
/** Sharing is off globally, or an explicit storage env override takes precedence. */
|
||||
export const isSharedStoreDisabled = (env: NodeJS.ProcessEnv = process.env): boolean => {
|
||||
const value = env[SHARED_STORE_ENV];
|
||||
if (value !== undefined && DISABLED_VALUES.has(value.trim().toLowerCase())) return true;
|
||||
return env[STORAGE_PATH_ENV] !== undefined || env[STORAGE_ROOT_ENV] !== undefined;
|
||||
};
|
||||
|
||||
// Every filesystem sink below rebuilds its path under a fixed parent and keeps
|
||||
// an inline `path.relative` barrier on that value: checkout paths arrive from
|
||||
// the HTTP analyze API and CodeQL does not treat a helper as a sanitizer.
|
||||
|
||||
const hasLinkedWorktrees = (commonDir: string): boolean => {
|
||||
const parent = path.resolve(commonDir);
|
||||
const worktrees = path.resolve(parent, 'worktrees');
|
||||
const rel = path.relative(parent, worktrees);
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
try {
|
||||
return fs.readdirSync(worktrees).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Read a small text file inside `parent`, or null when it is absent/unreadable. */
|
||||
const readFileIn = (
|
||||
parent: string,
|
||||
name: string,
|
||||
): { text: string } | { directory: true } | null => {
|
||||
const root = path.resolve(parent);
|
||||
const target = path.resolve(root, name);
|
||||
const rel = path.relative(root, target);
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
try {
|
||||
return { text: fs.readFileSync(target, 'utf-8') };
|
||||
} catch (err) {
|
||||
// A single read (no stat first) avoids a check-then-use race; a directory
|
||||
// answers with EISDIR.
|
||||
return (err as NodeJS.ErrnoException).code === 'EISDIR' ? { directory: true } : null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the git common dir for a tree root, or null when `checkoutPath` is
|
||||
* not a tree root (non-git folder or an arbitrary subdirectory).
|
||||
*/
|
||||
const readCommonDir = (checkoutPath: string): string | null => {
|
||||
const root = path.resolve(checkoutPath);
|
||||
const dotGit = readFileIn(root, '.git');
|
||||
if (!dotGit) return null;
|
||||
if ('directory' in dotGit) return path.join(root, '.git');
|
||||
|
||||
// Linked worktree: `.git` is a file `gitdir: <common>/worktrees/<name>`, and
|
||||
// that per-worktree dir holds a `commondir` file pointing back at <common>.
|
||||
const match = /^gitdir:\s*(.+?)\s*$/m.exec(dotGit.text);
|
||||
if (!match) return null;
|
||||
const gitDir = path.resolve(root, match[1]);
|
||||
const common = readFileIn(gitDir, 'commondir');
|
||||
// Submodules also use a `gitdir:` file but have no `commondir`; they are
|
||||
// standalone repositories, not linked worktrees.
|
||||
if (!common || !('text' in common)) return null;
|
||||
return path.resolve(gitDir, common.text.trim());
|
||||
};
|
||||
|
||||
/**
|
||||
* Store key for a checkout, or null when the checkout does not share.
|
||||
* Main checkout and every linked worktree of one repository get the same key.
|
||||
*/
|
||||
const resolveIdentity = (
|
||||
checkoutPath: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): { key: string; canonicalCheckout: string | null } | null => {
|
||||
if (isSharedStoreDisabled(env)) return null;
|
||||
const commonDir = readCommonDir(path.resolve(checkoutPath));
|
||||
if (!commonDir || !hasLinkedWorktrees(commonDir)) return null;
|
||||
// `<repo>/.git` keys on `<repo>` for a readable name; a bare common dir
|
||||
// (`repo.git`) keys on itself. Both hash the canonical absolute path.
|
||||
const canonicalCheckout = path.basename(commonDir) === '.git' ? path.dirname(commonDir) : null;
|
||||
return { key: slotName(canonicalCheckout ?? commonDir), canonicalCheckout };
|
||||
};
|
||||
|
||||
/**
|
||||
* Key for a store founded by a standalone clone (#3352): the clone's own
|
||||
* checkout path, in the same canonical form a main checkout keys on.
|
||||
*/
|
||||
export const cloneStoreKey = (checkoutPath: string): string => slotName(checkoutPath);
|
||||
|
||||
export const resolveSharedStoreKey = (
|
||||
checkoutPath: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | null => resolveIdentity(checkoutPath, env)?.key ?? null;
|
||||
|
||||
/** Name every store path for `checkoutPath` under store `key`. */
|
||||
export const sharedStoreLayout = (
|
||||
key: string,
|
||||
checkoutPath: string,
|
||||
canonicalCheckout: string | null = null,
|
||||
): SharedStoreLayout => {
|
||||
const storesRoot = path.join(getGlobalDir(), STORES_DIR);
|
||||
const root = path.resolve(storesRoot, key);
|
||||
if (path.dirname(root) !== path.resolve(storesRoot)) {
|
||||
throw new Error(`Shared store key escapes the stores directory: ${key}`);
|
||||
}
|
||||
const checkoutsDir = path.join(root, 'checkouts');
|
||||
return {
|
||||
key,
|
||||
root,
|
||||
cachesDir: path.join(root, 'caches'),
|
||||
commitsDir: path.join(root, 'commits'),
|
||||
checkoutsDir,
|
||||
checkoutSlot: path.join(checkoutsDir, slotName(checkoutPath)),
|
||||
canonicalCheckout,
|
||||
};
|
||||
};
|
||||
|
||||
/** Resolve the full layout for a checkout, or null when it does not share. */
|
||||
export const resolveSharedStore = (
|
||||
checkoutPath: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): SharedStoreLayout | null => {
|
||||
const identity = resolveIdentity(checkoutPath, env);
|
||||
return identity
|
||||
? sharedStoreLayout(identity.key, checkoutPath, identity.canonicalCheckout)
|
||||
: null;
|
||||
};
|
||||
|
||||
/** Directory of the immutable graph for one commit and feature key. */
|
||||
export const commitGraphDir = (
|
||||
layout: SharedStoreLayout,
|
||||
commit: string,
|
||||
featureKey: string,
|
||||
): string => {
|
||||
if (!COMMIT_RE.test(commit)) throw new Error(`Invalid commit id for shared store: ${commit}`);
|
||||
if (!FEATURE_KEY_RE.test(featureKey)) {
|
||||
throw new Error(`Invalid feature key for shared store: ${featureKey}`);
|
||||
}
|
||||
return path.join(layout.commitsDir, `${commit}-${featureKey}`);
|
||||
};
|
||||
|
||||
// A single path segment: `..repo-<hash>` is a legal slot name, `..` is not.
|
||||
const isDirectChild = (parent: string, child: string): boolean => {
|
||||
const rel = path.relative(parent, child);
|
||||
return rel !== '' && rel !== '..' && !path.isAbsolute(rel) && !rel.includes(path.sep);
|
||||
};
|
||||
|
||||
/**
|
||||
* Store root for a checkout slot (`<stores>/<key>/checkouts/<slot>`), or null
|
||||
* when `storagePath` is not a checkout slot. Pure path check — no I/O.
|
||||
*/
|
||||
export const storeRootOfCheckoutSlot = (storagePath: string): string | null => {
|
||||
const storesRoot = path.resolve(getGlobalDir(), STORES_DIR);
|
||||
const slot = path.resolve(storagePath);
|
||||
const checkoutsDir = path.dirname(slot);
|
||||
const root = path.dirname(checkoutsDir);
|
||||
if (path.basename(checkoutsDir) !== 'checkouts') return null;
|
||||
if (!isDirectChild(checkoutsDir, slot) || !isDirectChild(storesRoot, root)) return null;
|
||||
return root;
|
||||
};
|
||||
|
||||
/**
|
||||
* The graph a flat storage slot reads.
|
||||
*
|
||||
* Non-shared storage is always `<storagePath>/lbug`, with no I/O. A shared
|
||||
* checkout slot may record `graphPath` in its metadata, naming a commit graph
|
||||
* in the same store; any other recorded value (outside the store, a sibling's
|
||||
* private slot, unreadable metadata) falls back to the slot's own graph so a
|
||||
* hand-edited file cannot redirect reads.
|
||||
*/
|
||||
// Store-slot metadata can be megabytes (file hashes, cache keys) and this runs
|
||||
// on hot paths (MCP repo refresh, every getStoragePaths). Re-parse only when
|
||||
// the file's identity changes; a stat is the per-call cost.
|
||||
// ponytail: unbounded map keyed by slot path — one entry per shared checkout,
|
||||
// small; add eviction if a process ever tracks thousands of slots.
|
||||
const recordedGraphCache = new Map<string, { key: string; recorded: unknown }>();
|
||||
|
||||
const readRecordedGraphPath = (slot: string): unknown => {
|
||||
const root = path.resolve(slot);
|
||||
const metaPath = path.resolve(root, INDEX_METADATA_FILE);
|
||||
const rel = path.relative(root, metaPath);
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) return undefined;
|
||||
// One open, then stat and read through the same descriptor, so the cache
|
||||
// key always describes the bytes that were parsed.
|
||||
const fd = fs.openSync(metaPath, 'r');
|
||||
try {
|
||||
const stat = fs.fstatSync(fd);
|
||||
const key = `${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
||||
const cached = recordedGraphCache.get(metaPath);
|
||||
if (cached?.key === key) return cached.recorded;
|
||||
const recorded = (JSON.parse(fs.readFileSync(fd, 'utf-8')) as { graphPath?: unknown })
|
||||
.graphPath;
|
||||
recordedGraphCache.set(metaPath, { key, recorded });
|
||||
return recorded;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveGraphPath = (storagePath: string): string => {
|
||||
const own = path.join(storagePath, LBUG_DIRECTORY);
|
||||
const root = storeRootOfCheckoutSlot(storagePath);
|
||||
if (!root) return own;
|
||||
let recorded: unknown;
|
||||
try {
|
||||
recorded = readRecordedGraphPath(storagePath);
|
||||
} catch {
|
||||
return own;
|
||||
}
|
||||
if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own;
|
||||
const graph = path.resolve(recorded);
|
||||
const commitDir = path.dirname(graph);
|
||||
// Only a published `<commit>-<featureKey>` dir, never `.publish-*` staging.
|
||||
const valid =
|
||||
path.basename(graph) === LBUG_DIRECTORY &&
|
||||
isDirectChild(path.join(root, 'commits'), commitDir) &&
|
||||
COMMIT_GRAPH_DIR_RE.test(path.basename(commitDir));
|
||||
return valid ? graph : own;
|
||||
};
|
||||
|
||||
/**
|
||||
* The store slot named by `<checkout>/.gitnexus/store.json`, or null. The
|
||||
* recorded slot must be this checkout's own slot under the stores directory,
|
||||
* so a copied or hand-edited pointer cannot redirect reads to another index.
|
||||
*/
|
||||
export const readSharedStorePointer = (checkoutPath: string): string | null => {
|
||||
const root = path.resolve(checkoutPath);
|
||||
const pointerPath = path.resolve(root, GITNEXUS_DIR, SHARED_STORE_POINTER);
|
||||
const pointerRel = path.relative(root, pointerPath);
|
||||
if (pointerRel.startsWith('..') || path.isAbsolute(pointerRel)) return null;
|
||||
let pointer: { checkoutSlot?: unknown; storeKey?: unknown };
|
||||
try {
|
||||
pointer = JSON.parse(fs.readFileSync(pointerPath, 'utf-8')) as typeof pointer;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!pointer || typeof pointer !== 'object' || Array.isArray(pointer)) return null;
|
||||
const { checkoutSlot: recorded, storeKey } = pointer;
|
||||
if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return null;
|
||||
if (typeof storeKey !== 'string') return null;
|
||||
const slot = path.resolve(recorded);
|
||||
const storeRoot = storeRootOfCheckoutSlot(slot);
|
||||
if (!storeRoot || path.basename(storeRoot) !== storeKey) return null;
|
||||
if (slot !== sharedStoreLayout(storeKey, checkoutPath).checkoutSlot) return null;
|
||||
// The pointer file is editable, so its fields only say where to look. The
|
||||
// binding is the slot's own metadata, written by analyze for this checkout:
|
||||
// a slot in another store for this path exists only if this checkout was
|
||||
// really a member there.
|
||||
const metaPath = path.resolve(slot, INDEX_METADATA_FILE);
|
||||
const metaRel = path.relative(slot, metaPath);
|
||||
if (metaRel.startsWith('..') || path.isAbsolute(metaRel)) return null;
|
||||
let owner: unknown;
|
||||
try {
|
||||
owner = (JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as { repoPath?: unknown }).repoPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return typeof owner === 'string' && slotName(owner) === slotName(checkoutPath) ? slot : null;
|
||||
};
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import fs from 'fs';
|
||||
import fsp from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
|
@ -10,14 +9,26 @@ import {
|
|||
LEGACY_METADATA_FILE,
|
||||
LBUG_DIRECTORY,
|
||||
} from './storage-constants.js';
|
||||
import {
|
||||
readSharedStorePointer,
|
||||
resolveGraphPath,
|
||||
resolveSharedStore,
|
||||
SHARED_STORE_POINTER,
|
||||
} from './shared-store.js';
|
||||
import { slotNameForCanonicalPath, STORAGE_PATH_ENV, STORAGE_ROOT_ENV } from './storage-slot.js';
|
||||
|
||||
export const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH';
|
||||
export const STORAGE_ROOT_ENV = 'GITNEXUS_STORAGE_ROOT';
|
||||
|
||||
const STORAGE_SLOT_HASH_LENGTH = 12;
|
||||
export { STORAGE_PATH_ENV, STORAGE_ROOT_ENV };
|
||||
|
||||
/** File-backend lock sidecars (`index-lock.ts`). Not ownership data. */
|
||||
const INDEX_LOCK_ARTIFACTS = new Set(['analyze.lock', 'analyze.lock.guard']);
|
||||
const INDEX_LOCK_ARTIFACTS = new Set([
|
||||
'analyze.lock',
|
||||
'analyze.lock.guard',
|
||||
// A shared-store checkout's pointer (#3352) and the ignore file beside it
|
||||
// are not index data either.
|
||||
SHARED_STORE_POINTER,
|
||||
'.gitignore',
|
||||
'run.cjs',
|
||||
]);
|
||||
|
||||
export type StorageState =
|
||||
| 'invalid_param'
|
||||
|
|
@ -211,37 +222,13 @@ const comparablePath = (value: string): string => {
|
|||
return process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
||||
};
|
||||
|
||||
const sanitizeSlotBasename = (value: string): string => {
|
||||
// Linear: a quantified `/[. ]+$/` on attacker-controlled basenames is
|
||||
// js/polynomial-redos (CodeQL #1056). Cap first, then walk the tail once.
|
||||
const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80);
|
||||
let end = sanitized.length;
|
||||
while (end > 0) {
|
||||
const code = sanitized.charCodeAt(end - 1);
|
||||
if (code !== 0x20 && code !== 0x2e) break;
|
||||
end--;
|
||||
}
|
||||
const candidate = sanitized.slice(0, end) || 'repository';
|
||||
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate)
|
||||
? `repository-${candidate}`
|
||||
: candidate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable slot name for one checkout inside a configured external storage root.
|
||||
* The canonical absolute path prevents symlink aliases from creating duplicate
|
||||
* slots, while the hash keeps same-basename repositories isolated.
|
||||
*/
|
||||
export const storageSlotName = (repoPath: string): string => {
|
||||
const canonical = canonicalRepoPath(repoPath);
|
||||
const identity = process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
||||
const basename = sanitizeSlotBasename(path.basename(canonical));
|
||||
const digest = createHash('sha256')
|
||||
.update(identity)
|
||||
.digest('hex')
|
||||
.slice(0, STORAGE_SLOT_HASH_LENGTH);
|
||||
return `${basename}-${digest}`;
|
||||
};
|
||||
export const storageSlotName = (repoPath: string): string =>
|
||||
slotNameForCanonicalPath(canonicalRepoPath(repoPath));
|
||||
|
||||
export const defaultStoragePath = (repoPath: string): string =>
|
||||
path.join(resolveRepoPath(repoPath), GITNEXUS_DIR);
|
||||
|
|
@ -335,6 +322,14 @@ export const resolveStoragePath = (repoPath: string): string => {
|
|||
const registered = registeredStoragePath(resolvedRepoPath);
|
||||
if (registered) return registered;
|
||||
|
||||
// Shared sibling store (#3352): an unregistered checkout whose slot already
|
||||
// exists (for example after the registry was reset). Checkouts only move INTO
|
||||
// the store at analyze time; a read never switches to an empty slot.
|
||||
const shared = resolveSharedStore(resolvedRepoPath);
|
||||
if (shared && fs.existsSync(shared.checkoutSlot)) return shared.checkoutSlot;
|
||||
const pointed = readSharedStorePointer(resolvedRepoPath);
|
||||
if (pointed && fs.existsSync(pointed)) return pointed;
|
||||
|
||||
return defaultStoragePath(resolvedRepoPath);
|
||||
};
|
||||
|
||||
|
|
@ -407,8 +402,12 @@ const inspectCodeIndexDB = async (
|
|||
if (lbugRel.startsWith('..') || path.isAbsolute(lbugRel)) {
|
||||
return { present: false };
|
||||
}
|
||||
// A shared-store checkout slot (#3352) may read a commit graph instead of
|
||||
// owning one; `resolveGraphPath` only returns a path inside the same store's
|
||||
// commit graphs, else the slot's own graph.
|
||||
const graphPath = resolveGraphPath(resolved);
|
||||
try {
|
||||
await fsp.access(lbugPath);
|
||||
await fsp.access(graphPath);
|
||||
return { present: true };
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
|
|
|
|||
56
gitnexus/src/storage/storage-slot.ts
Normal file
56
gitnexus/src/storage/storage-slot.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Leaf naming primitives for external index slots.
|
||||
*
|
||||
* Kept free of imports from `storage-resolver.ts` and `shared-store.ts` so both
|
||||
* can use them without importing each other (#3352). `storage-resolver.ts`
|
||||
* re-exports the two env-var names, so existing import sites are unchanged.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'path';
|
||||
|
||||
export const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH';
|
||||
export const STORAGE_ROOT_ENV = 'GITNEXUS_STORAGE_ROOT';
|
||||
|
||||
const STORAGE_SLOT_HASH_LENGTH = 12;
|
||||
|
||||
/** Exported for tests; production callers use {@link slotNameForCanonicalPath}. */
|
||||
export const sanitizeSlotBasename = (
|
||||
value: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string => {
|
||||
// Linear: a quantified `/[. ]+$/` on attacker-controlled basenames is
|
||||
// js/polynomial-redos (CodeQL #1056). Cap first, then walk the tail once.
|
||||
const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80);
|
||||
let end = sanitized.length;
|
||||
while (end > 0) {
|
||||
const code = sanitized.charCodeAt(end - 1);
|
||||
if (code !== 0x20 && code !== 0x2e) break;
|
||||
end--;
|
||||
}
|
||||
const candidate = sanitized.slice(0, end) || 'repository';
|
||||
// Exact device names were always prefixed. Windows also reserves them with
|
||||
// an extension (`CON.txt`); apply that only there, so existing POSIX slot
|
||||
// names stay stable. All four registry-query.cjs hook copies mirror this;
|
||||
// hooks-shared-store.test.ts checks hook-vs-TS parity on both platforms.
|
||||
const reserved =
|
||||
platform === 'win32'
|
||||
? /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
|
||||
: /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
||||
return reserved.test(candidate) ? `repository-${candidate}` : candidate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Slot name for an already-canonical absolute path: sanitized basename plus a
|
||||
* short hash of the path (case-folded on Windows) so same-basename paths stay
|
||||
* isolated.
|
||||
*/
|
||||
export const slotNameForCanonicalPath = (canonical: string): string => {
|
||||
const identity = process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
||||
const basename = sanitizeSlotBasename(path.basename(canonical));
|
||||
const digest = createHash('sha256')
|
||||
.update(identity)
|
||||
.digest('hex')
|
||||
.slice(0, STORAGE_SLOT_HASH_LENGTH);
|
||||
return `${basename}-${digest}`;
|
||||
};
|
||||
264
gitnexus/test/integration/shared-store-adoption.test.ts
Normal file
264
gitnexus/test/integration/shared-store-adoption.test.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { execFileSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
|
||||
import {
|
||||
readSharedStorePointer,
|
||||
resolveSharedStore,
|
||||
SHARED_STORE_ENV,
|
||||
type SharedStoreLayout,
|
||||
} from '../../src/storage/shared-store.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
/**
|
||||
* #3352 U8 — existing worktree indexes are adopted into the store without
|
||||
* being deleted, and status/doctor/clean make the leftover visible and
|
||||
* removable.
|
||||
*/
|
||||
const git = (cwd: string, ...args: string[]): string =>
|
||||
execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
||||
|
||||
const layoutOf = (checkout: string): SharedStoreLayout => {
|
||||
const layout = resolveSharedStore(checkout);
|
||||
expect(layout).not.toBeNull();
|
||||
return layout as SharedStoreLayout;
|
||||
};
|
||||
|
||||
describe('shared store adoption and reporting (#3352)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
let savedSwitch: string | undefined;
|
||||
let savedCwd: string;
|
||||
let main: string;
|
||||
let wt: string;
|
||||
|
||||
const analyze = async (checkout: string) => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
return runFullAnalysis(checkout, {}, { onProgress: () => {} });
|
||||
};
|
||||
|
||||
const runIn = async (checkout: string, fn: () => Promise<void>): Promise<string[]> => {
|
||||
process.chdir(checkout);
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
await fn();
|
||||
return log.mock.calls.map((c) => String(c[0]));
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
process.chdir(savedCwd);
|
||||
}
|
||||
};
|
||||
|
||||
const statusJson = async (checkout: string) => {
|
||||
const { statusCommand } = await import('../../src/cli/status.js');
|
||||
const lines = await runIn(checkout, () => statusCommand({ json: true }));
|
||||
return JSON.parse(lines[lines.length - 1]) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Index `wt` the pre-store way, into its own `.gitnexus`. */
|
||||
const legacyIndex = async (checkout: string): Promise<void> => {
|
||||
process.env[SHARED_STORE_ENV] = 'off';
|
||||
try {
|
||||
await analyze(checkout);
|
||||
} finally {
|
||||
delete process.env[SHARED_STORE_ENV];
|
||||
}
|
||||
expect(existsSync(path.join(checkout, '.gitnexus', 'lbug'))).toBe(true);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
savedCwd = process.cwd();
|
||||
tmpHome = await createTempDir('gitnexus-test-adopt-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-test-adopt-repo-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
savedSwitch = process.env[SHARED_STORE_ENV];
|
||||
delete process.env[SHARED_STORE_ENV];
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
const root = await fs.realpath(tmpRepo.dbPath);
|
||||
main = path.join(root, 'main');
|
||||
await fs.mkdir(main);
|
||||
git(main, 'init', '-q', '-b', 'main');
|
||||
await fs.writeFile(path.join(main, 'a.ts'), 'export function alpha() { return 1; }\n');
|
||||
git(main, 'add', '-A');
|
||||
git(main, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'init');
|
||||
wt = path.join(root, 'wt');
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt', wt);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.chdir(savedCwd);
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
if (savedSwitch === undefined) delete process.env[SHARED_STORE_ENV];
|
||||
else process.env[SHARED_STORE_ENV] = savedSwitch;
|
||||
await tmpRepo.cleanup();
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('Covers F4: adopts a worktree index into the store and leaves the old files', async () => {
|
||||
await legacyIndex(wt);
|
||||
const legacyGraph = await fs.readFile(path.join(wt, '.gitnexus', 'lbug'));
|
||||
|
||||
const result = await analyze(wt);
|
||||
|
||||
// Seeded from its own index, then verified by a file-hash diff rather than
|
||||
// trusted: a local index may hold edits that were later reverted.
|
||||
expect(result.alreadyUpToDate).not.toBe(true);
|
||||
const layout = layoutOf(wt);
|
||||
expect((await fs.readdir(layout.commitsDir)).filter((n) => !n.startsWith('.'))).toHaveLength(1);
|
||||
expect(await fs.readFile(path.join(wt, '.gitnexus', 'lbug'))).toEqual(legacyGraph);
|
||||
expect(readSharedStorePointer(wt)).toBe(layout.checkoutSlot);
|
||||
expect((await listRegisteredRepos()).find((e) => e.path === wt)?.storagePath).toBe(
|
||||
layout.checkoutSlot,
|
||||
);
|
||||
}, 240_000);
|
||||
|
||||
it('status reports the shared graph and the leftover index with its removal command', async () => {
|
||||
await legacyIndex(wt);
|
||||
await analyze(wt);
|
||||
|
||||
const json = await statusJson(wt);
|
||||
expect(json.sharedStore).toEqual({
|
||||
key: layoutOf(wt).key,
|
||||
graph: 'shared',
|
||||
commit: git(wt, 'rev-parse', 'HEAD'),
|
||||
privateClone: null,
|
||||
});
|
||||
expect(json.legacyLocalIndex).toMatchObject({ path: path.join(wt, '.gitnexus') });
|
||||
|
||||
const { statusCommand } = await import('../../src/cli/status.js');
|
||||
const text = (await runIn(wt, () => statusCommand({}))).join('\n');
|
||||
expect(text).toMatch(/shared graph for commit/);
|
||||
expect(text).toMatch(/gitnexus clean --local-index --force/);
|
||||
}, 240_000);
|
||||
|
||||
it('clean --local-index deletes only the leftover files and keeps the pointer', async () => {
|
||||
await legacyIndex(wt);
|
||||
await analyze(wt);
|
||||
const { cleanCommand } = await import('../../src/cli/clean.js');
|
||||
|
||||
await runIn(wt, () => cleanCommand({ localIndex: true }));
|
||||
expect(existsSync(path.join(wt, '.gitnexus', 'lbug'))).toBe(true); // preview only
|
||||
|
||||
await runIn(wt, () => cleanCommand({ localIndex: true, force: true }));
|
||||
expect((await fs.readdir(path.join(wt, '.gitnexus'))).sort()).toEqual([
|
||||
'.gitignore',
|
||||
'run.cjs',
|
||||
'store.json',
|
||||
]);
|
||||
expect((await statusJson(wt)).legacyLocalIndex).toBeNull();
|
||||
expect((await statusJson(wt)).status).toBe('up-to-date');
|
||||
}, 240_000);
|
||||
|
||||
it('status reports a pinned branch index as private even when the flat slot is shared', async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await analyze(wt);
|
||||
// The flat slot now holds `wt` and points at a shared commit graph. A
|
||||
// different checked-out branch pinned with --branch gets its own index.
|
||||
git(wt, 'checkout', '-q', '-b', 'pinned');
|
||||
await runFullAnalysis(wt, { branch: 'pinned' }, { onProgress: () => {} });
|
||||
const json = await statusJson(wt);
|
||||
expect(json.sharedStore).toMatchObject({ graph: 'private' });
|
||||
}, 240_000);
|
||||
|
||||
it('status reports a private graph for an edited worktree', async () => {
|
||||
await analyze(wt);
|
||||
await fs.writeFile(path.join(wt, 'a.ts'), 'export function alphaEdited() { return 1; }\n');
|
||||
await analyze(wt);
|
||||
expect((await statusJson(wt)).sharedStore).toMatchObject({ graph: 'private' });
|
||||
}, 240_000);
|
||||
|
||||
it('indexes into .gitnexus with sharing turned off and never writes the commit graph', async () => {
|
||||
await analyze(wt);
|
||||
const layout = layoutOf(wt);
|
||||
const commitDir = (await fs.readdir(layout.commitsDir)).find((n) => !n.startsWith('.'));
|
||||
const commitGraph = path.join(layout.commitsDir, commitDir as string, 'lbug');
|
||||
const before = await fs.readFile(commitGraph);
|
||||
|
||||
await fs.writeFile(path.join(wt, 'b.ts'), 'export function beta() { return 2; }\n');
|
||||
process.env[SHARED_STORE_ENV] = 'off';
|
||||
try {
|
||||
await analyze(wt);
|
||||
} finally {
|
||||
delete process.env[SHARED_STORE_ENV];
|
||||
}
|
||||
|
||||
expect(existsSync(path.join(wt, '.gitnexus', 'lbug'))).toBe(true);
|
||||
expect((await listRegisteredRepos()).find((e) => e.path === wt)?.storagePath).toBe(
|
||||
path.join(wt, '.gitnexus'),
|
||||
);
|
||||
expect(await fs.readFile(commitGraph)).toEqual(before);
|
||||
}, 240_000);
|
||||
|
||||
it('keeps committed agent docs pointing at a runner inside the checkout', async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(wt, { registryName: 'wt' }, { onProgress: () => {} });
|
||||
expect(existsSync(path.join(wt, '.gitnexus', 'run.cjs'))).toBe(true);
|
||||
const agents = await fs.readFile(path.join(wt, 'AGENTS.md'), 'utf-8');
|
||||
expect(agents).toContain('.gitnexus/run.cjs');
|
||||
expect(agents).not.toContain('stores/');
|
||||
}, 240_000);
|
||||
|
||||
it('drops pinned branch summaries when a checkout moves into the store', async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
process.env[SHARED_STORE_ENV] = 'off';
|
||||
try {
|
||||
await runFullAnalysis(wt, { branch: 'wt' }, { onProgress: () => {} });
|
||||
await analyze(wt);
|
||||
} finally {
|
||||
delete process.env[SHARED_STORE_ENV];
|
||||
}
|
||||
await analyze(wt);
|
||||
const entry = (await listRegisteredRepos()).find((e) => e.path === wt);
|
||||
expect(entry?.storagePath).toBe(layoutOf(wt).checkoutSlot);
|
||||
expect(entry?.branches).toBeUndefined();
|
||||
}, 240_000);
|
||||
|
||||
it('re-registers at .gitnexus when sharing is turned off on an up-to-date index', async () => {
|
||||
await legacyIndex(wt);
|
||||
await analyze(wt);
|
||||
process.env[SHARED_STORE_ENV] = 'off';
|
||||
try {
|
||||
const result = await analyze(wt);
|
||||
expect(result.alreadyUpToDate).toBe(true);
|
||||
} finally {
|
||||
delete process.env[SHARED_STORE_ENV];
|
||||
}
|
||||
expect((await listRegisteredRepos()).find((e) => e.path === wt)?.storagePath).toBe(
|
||||
path.join(wt, '.gitnexus'),
|
||||
);
|
||||
expect(readSharedStorePointer(wt)).toBeNull();
|
||||
}, 240_000);
|
||||
|
||||
it('rejects a pointer that names another checkout slot', async () => {
|
||||
await analyze(wt);
|
||||
const pointer = path.join(wt, '.gitnexus', 'store.json');
|
||||
const other = layoutOf(main).checkoutSlot;
|
||||
await fs.writeFile(pointer, JSON.stringify({ version: 1, checkoutSlot: other }));
|
||||
expect(readSharedStorePointer(wt)).toBeNull();
|
||||
const ownSlot = layoutOf(wt).checkoutSlot;
|
||||
const otherStoreSlot = path.join(
|
||||
path.dirname(path.dirname(path.dirname(ownSlot))),
|
||||
'other-000000000000',
|
||||
'checkouts',
|
||||
path.basename(ownSlot),
|
||||
);
|
||||
await fs.writeFile(
|
||||
pointer,
|
||||
JSON.stringify({ version: 1, storeKey: 'other-000000000000', checkoutSlot: otherStoreSlot }),
|
||||
);
|
||||
expect(readSharedStorePointer(wt)).toBeNull();
|
||||
await fs.writeFile(
|
||||
pointer,
|
||||
JSON.stringify({ version: 1, storeKey: layoutOf(wt).key, checkoutSlot: otherStoreSlot }),
|
||||
);
|
||||
expect(readSharedStorePointer(wt)).toBeNull();
|
||||
await fs.writeFile(pointer, JSON.stringify({ version: 1, checkoutSlot: '/etc' }));
|
||||
expect(readSharedStorePointer(wt)).toBeNull();
|
||||
await fs.writeFile(pointer, 'not json');
|
||||
expect(readSharedStorePointer(wt)).toBeNull();
|
||||
}, 240_000);
|
||||
});
|
||||
645
gitnexus/test/integration/shared-store-analyze.test.ts
Normal file
645
gitnexus/test/integration/shared-store-analyze.test.ts
Normal file
|
|
@ -0,0 +1,645 @@
|
|||
import { execFileSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js';
|
||||
import { resolveAnalyzerRunnerIdentity } from '../../src/core/analyzer-identity.js';
|
||||
import type { EmbeddingCheckpoint } from '../../src/core/embedding-checkpoint.js';
|
||||
import { SCHEMA_FINGERPRINT } from '../../src/core/lbug/schema.js';
|
||||
import {
|
||||
ensurePrivateSharedGraph,
|
||||
featureKeyOf,
|
||||
listStoreMetaRoots,
|
||||
publishSharedGraph,
|
||||
seedSharedSlot,
|
||||
} from '../../src/core/shared-store-analyze.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
listRegisteredRepos,
|
||||
loadMeta,
|
||||
saveMeta,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import type { RepoMeta } from '../../src/storage/repo-meta.js';
|
||||
import {
|
||||
commitGraphDir,
|
||||
resolveGraphPath,
|
||||
resolveSharedStore,
|
||||
type SharedStoreLayout,
|
||||
} from '../../src/storage/shared-store.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
// These suites exercise sharing; an inherited opt-out would silently disable it.
|
||||
const savedSharedStoreSwitch = process.env.GITNEXUS_SHARED_STORE;
|
||||
beforeAll(() => {
|
||||
delete process.env.GITNEXUS_SHARED_STORE;
|
||||
});
|
||||
afterAll(() => {
|
||||
if (savedSharedStoreSwitch === undefined) delete process.env.GITNEXUS_SHARED_STORE;
|
||||
else process.env.GITNEXUS_SHARED_STORE = savedSharedStoreSwitch;
|
||||
});
|
||||
|
||||
/**
|
||||
* #3352 — linked worktrees at one commit share one immutable commit graph in
|
||||
* the store under GITNEXUS_HOME, and a second worktree's analyze reuses it
|
||||
* without writing a graph.
|
||||
*/
|
||||
const git = (cwd: string, ...args: string[]): string =>
|
||||
execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
||||
|
||||
const commitAll = (cwd: string, message: string): void => {
|
||||
git(cwd, 'add', '-A');
|
||||
git(cwd, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', message);
|
||||
};
|
||||
|
||||
const layoutOf = (checkout: string): SharedStoreLayout => {
|
||||
const layout = resolveSharedStore(checkout);
|
||||
expect(layout).not.toBeNull();
|
||||
return layout as SharedStoreLayout;
|
||||
};
|
||||
|
||||
const listCommitDirs = async (layout: SharedStoreLayout): Promise<string[]> =>
|
||||
(await fs.readdir(layout.commitsDir).catch(() => [] as string[])).filter(
|
||||
(name) => !name.startsWith('.'),
|
||||
);
|
||||
|
||||
describe('shared sibling store analyze (#3352)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
let main: string;
|
||||
let wtA: string;
|
||||
let wtB: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-test-shared-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-test-shared-repo-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
|
||||
const root = await fs.realpath(tmpRepo.dbPath);
|
||||
main = path.join(root, 'main');
|
||||
wtA = path.join(root, 'wt-a');
|
||||
wtB = path.join(root, 'wt-b');
|
||||
await fs.mkdir(main);
|
||||
git(main, 'init', '-q', '-b', 'main');
|
||||
await fs.writeFile(
|
||||
path.join(main, 'a.ts'),
|
||||
'export function a() { return b(); }\nexport function b() { return 1; }\n',
|
||||
);
|
||||
commitAll(main, 'init');
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt-a', wtA);
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt-b', wtB);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpRepo.cleanup();
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('Covers AE1: three clean worktrees at one commit share one commit graph', async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
const results = [];
|
||||
for (const checkout of [main, wtA, wtB]) {
|
||||
results.push(await runFullAnalysis(checkout, {}, { onProgress: () => {} }));
|
||||
}
|
||||
|
||||
const layout = layoutOf(main);
|
||||
const commitDirs = await listCommitDirs(layout);
|
||||
expect(commitDirs).toHaveLength(1);
|
||||
const graph = path.join(layout.commitsDir, commitDirs[0], 'lbug');
|
||||
|
||||
for (const checkout of [main, wtA, wtB]) {
|
||||
const slot = layoutOf(checkout).checkoutSlot;
|
||||
expect(getStoragePaths(checkout, undefined, slot).lbugPath).toBe(graph);
|
||||
expect(existsSync(path.join(slot, 'lbug'))).toBe(false);
|
||||
// The pre-existing repository-local index location is never written.
|
||||
expect(existsSync(path.join(checkout, '.gitnexus', 'lbug'))).toBe(false);
|
||||
}
|
||||
// Siblings after the first reuse the published graph without a pipeline run.
|
||||
expect(results.map((r) => r.alreadyUpToDate === true)).toEqual([false, true, true]);
|
||||
|
||||
const registered = await listRegisteredRepos();
|
||||
for (const checkout of [main, wtA, wtB]) {
|
||||
const entry = registered.find((e) => e.path === checkout);
|
||||
expect(entry?.storagePath).toBe(layoutOf(checkout).checkoutSlot);
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
it('reports a checkout that reads a commit graph as indexed', async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(wtA, {}, { onProgress: () => {} });
|
||||
await runFullAnalysis(wtB, {}, { onProgress: () => {} });
|
||||
const slot = layoutOf(wtB).checkoutSlot;
|
||||
expect(existsSync(path.join(slot, 'lbug'))).toBe(false);
|
||||
|
||||
const { inspectRegisteredStorage } = await import('../../src/storage/storage-resolver.js');
|
||||
const inspection = await inspectRegisteredStorage({ path: wtB, storagePath: slot });
|
||||
expect(inspection).toMatchObject({ state: 'owned', hasCodeIndexDB: true });
|
||||
const validated = await listRegisteredRepos({ validate: true });
|
||||
expect(validated.map((e) => e.path)).toEqual(expect.arrayContaining([wtA, wtB]));
|
||||
}, 180_000);
|
||||
|
||||
it('Covers AE1: MCP opens one database for three checkouts on one commit graph', async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
for (const checkout of [main, wtA, wtB]) {
|
||||
await runFullAnalysis(checkout, {}, { onProgress: () => {} });
|
||||
}
|
||||
const graphs = [main, wtA, wtB].map(
|
||||
(c) => getStoragePaths(c, undefined, layoutOf(c).checkoutSlot).lbugPath,
|
||||
);
|
||||
expect(new Set(graphs).size).toBe(1);
|
||||
|
||||
const { initLbug, closeLbug } = await import('../../src/core/lbug/pool-adapter.js');
|
||||
const savedTrace = process.env.GITNEXUS_POOL_RSS_TRACE;
|
||||
process.env.GITNEXUS_POOL_RSS_TRACE = '1';
|
||||
const traces: string[] = [];
|
||||
const write = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = ((chunk: string | Uint8Array, ...rest: unknown[]) => {
|
||||
if (String(chunk).startsWith('[pool-rss]')) traces.push(String(chunk));
|
||||
return (write as (...a: unknown[]) => boolean)(chunk, ...rest);
|
||||
}) as typeof process.stderr.write;
|
||||
try {
|
||||
for (const [i, graph] of graphs.entries()) await initLbug(`shared-${i}`, graph);
|
||||
} finally {
|
||||
process.stderr.write = write;
|
||||
if (savedTrace === undefined) delete process.env.GITNEXUS_POOL_RSS_TRACE;
|
||||
else process.env.GITNEXUS_POOL_RSS_TRACE = savedTrace;
|
||||
await closeLbug();
|
||||
}
|
||||
const last = traces.filter((t) => t.includes(' init ')).pop();
|
||||
expect(last).toMatch(/pool=3 dbCache=1 /);
|
||||
}, 240_000);
|
||||
|
||||
it('serves a sibling the same relative file paths from the shared graph', async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(wtA, {}, { onProgress: () => {} });
|
||||
await runFullAnalysis(wtB, {}, { onProgress: () => {} });
|
||||
|
||||
const lbug = (await import('@ladybugdb/core')).default;
|
||||
const graph = getStoragePaths(wtB, undefined, layoutOf(wtB).checkoutSlot).lbugPath;
|
||||
const db = new lbug.Database(graph, 0, true, true);
|
||||
const conn = new lbug.Connection(db);
|
||||
let rows: { p: string }[];
|
||||
try {
|
||||
rows = (await (
|
||||
await conn.query('MATCH (f:File) RETURN f.filePath AS p ORDER BY p')
|
||||
).getAll()) as { p: string }[];
|
||||
} finally {
|
||||
await conn.close();
|
||||
await db.close();
|
||||
}
|
||||
expect(rows.map((r) => r.p)).toEqual(['a.ts']);
|
||||
}, 180_000);
|
||||
|
||||
it('publishes a new commit graph when a clean worktree moves to a new commit', async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(wtA, {}, { onProgress: () => {} });
|
||||
await fs.writeFile(path.join(wtA, 'c.ts'), 'export const c = 3;\n');
|
||||
commitAll(wtA, 'c');
|
||||
await runFullAnalysis(wtA, {}, { onProgress: () => {} });
|
||||
|
||||
const layout = layoutOf(wtA);
|
||||
const head = git(wtA, 'rev-parse', 'HEAD');
|
||||
const meta = await loadMeta(layout.checkoutSlot);
|
||||
expect(meta?.lastCommit).toBe(head);
|
||||
expect(meta?.graphPath).toBe(
|
||||
path.join(commitGraphDir(layout, head, featureKeyOf(meta as RepoMeta)), 'lbug'),
|
||||
);
|
||||
// The previous commit's graph is no longer referenced and is reclaimed at once.
|
||||
expect(await listCommitDirs(layout)).toEqual([
|
||||
path.basename(path.dirname(meta?.graphPath as string)),
|
||||
]);
|
||||
}, 180_000);
|
||||
|
||||
it('never publishes a graph that was built from uncommitted edits', async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await fs.writeFile(path.join(wtA, 'a.ts'), 'export function uncommitted() { return 9; }\n');
|
||||
await runFullAnalysis(wtA, {}, { onProgress: () => {} });
|
||||
git(wtA, 'checkout', '--', 'a.ts');
|
||||
await runFullAnalysis(wtA, {}, { onProgress: () => {} });
|
||||
|
||||
const layout = layoutOf(wtA);
|
||||
expect(await listCommitDirs(layout)).toEqual([]);
|
||||
expect(existsSync(path.join(layout.checkoutSlot, 'lbug'))).toBe(true);
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
describe('featureKeyOf', () => {
|
||||
const base: RepoMeta = {
|
||||
repoPath: '/a',
|
||||
storagePath: '/a/.gitnexus',
|
||||
lastCommit: 'abc1234',
|
||||
indexedAt: '2026-01-01T00:00:00.000Z',
|
||||
schemaFingerprint: 'fp1',
|
||||
analysisFeatures: { x: 1 },
|
||||
};
|
||||
|
||||
it('ignores per-checkout and per-run fields', () => {
|
||||
expect(
|
||||
featureKeyOf({
|
||||
...base,
|
||||
repoPath: '/b',
|
||||
storagePath: '/b/.gitnexus',
|
||||
indexedAt: '2027-01-01T00:00:00.000Z',
|
||||
lastCommit: 'def5678',
|
||||
branch: 'feature',
|
||||
fileHashes: { 'a.ts': 'h' },
|
||||
stats: { nodes: 9 },
|
||||
}),
|
||||
).toBe(featureKeyOf(base));
|
||||
});
|
||||
|
||||
it('is independent of key order', () => {
|
||||
const reordered = Object.fromEntries(Object.entries(base).reverse()) as RepoMeta;
|
||||
expect(featureKeyOf(reordered)).toBe(featureKeyOf(base));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['schema fingerprint', { schemaFingerprint: 'fp2' }],
|
||||
['analysis features', { analysisFeatures: { x: 2 } }],
|
||||
['PDG layer', { pdg: {} as RepoMeta['pdg'] }],
|
||||
['content retention', { contentRetention: 'none' as const }],
|
||||
['embeddings present', { stats: { embeddings: 3 } }],
|
||||
])('changes with %s', (_label, delta) => {
|
||||
expect(featureKeyOf({ ...base, ...delta })).not.toBe(featureKeyOf(base));
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishSharedGraph race (#3352)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-test-shared-race-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-test-shared-race-repo-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpRepo.cleanup();
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
const setup = async (): Promise<{ checkouts: string[]; head: string }> => {
|
||||
const root = await fs.realpath(tmpRepo.dbPath);
|
||||
const main = path.join(root, 'main');
|
||||
await fs.mkdir(main);
|
||||
git(main, 'init', '-q', '-b', 'main');
|
||||
await fs.writeFile(path.join(main, 'a.ts'), 'export const a = 1;\n');
|
||||
commitAll(main, 'init');
|
||||
const wt = path.join(root, 'wt');
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt', wt);
|
||||
const head = git(main, 'rev-parse', 'HEAD');
|
||||
for (const checkout of [main, wt]) {
|
||||
const slot = layoutOf(checkout).checkoutSlot;
|
||||
await fs.mkdir(slot, { recursive: true });
|
||||
await fs.writeFile(path.join(slot, 'lbug'), `graph from ${checkout}`);
|
||||
await saveMeta(slot, {
|
||||
repoPath: checkout,
|
||||
storagePath: slot,
|
||||
lastCommit: head,
|
||||
indexedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return { checkouts: [main, wt], head };
|
||||
};
|
||||
|
||||
it('Covers AE6: two checkouts publishing one new commit produce exactly one graph', async () => {
|
||||
const { checkouts } = await setup();
|
||||
await Promise.all(
|
||||
checkouts.map((c) =>
|
||||
publishSharedGraph(layoutOf(c), c, git(c, 'rev-parse', 'HEAD'), () => {}),
|
||||
),
|
||||
);
|
||||
const layout = layoutOf(checkouts[0]);
|
||||
expect(await listCommitDirs(layout)).toHaveLength(1);
|
||||
const pointers = await Promise.all(
|
||||
checkouts.map(async (c) => (await loadMeta(layoutOf(c).checkoutSlot))?.graphPath),
|
||||
);
|
||||
expect(new Set(pointers).size).toBe(1);
|
||||
for (const c of checkouts) {
|
||||
expect(existsSync(path.join(layoutOf(c).checkoutSlot, 'lbug'))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a private graph whose sidecars are not consolidated', async () => {
|
||||
const { checkouts, head } = await setup();
|
||||
const [main] = checkouts;
|
||||
const slot = layoutOf(main).checkoutSlot;
|
||||
await fs.writeFile(path.join(slot, 'lbug.wal'), 'pending');
|
||||
await publishSharedGraph(layoutOf(main), main, head, () => {});
|
||||
expect(await listCommitDirs(layoutOf(main))).toEqual([]);
|
||||
expect(existsSync(path.join(slot, 'lbug'))).toBe(true);
|
||||
expect((await loadMeta(slot))?.graphPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a dirty checkout private', async () => {
|
||||
const { checkouts, head } = await setup();
|
||||
const [main] = checkouts;
|
||||
await fs.writeFile(path.join(main, 'a.ts'), 'export const a = 2;\n');
|
||||
await publishSharedGraph(layoutOf(main), main, head, () => {});
|
||||
expect(await listCommitDirs(layoutOf(main))).toEqual([]);
|
||||
expect(existsSync(path.join(layoutOf(main).checkoutSlot, 'lbug'))).toBe(true);
|
||||
});
|
||||
|
||||
// #3374: `git status` is clean in a sparse checkout, but the graph lacks the
|
||||
// files the checkout leaves out.
|
||||
it('keeps a checkout that hides committed files private', async () => {
|
||||
const { checkouts, head } = await setup();
|
||||
const [main] = checkouts;
|
||||
git(main, 'update-index', '--skip-worktree', '--', 'a.ts');
|
||||
await fs.rm(path.join(main, 'a.ts'));
|
||||
await publishSharedGraph(layoutOf(main), main, head, () => {});
|
||||
expect(await listCommitDirs(layoutOf(main))).toEqual([]);
|
||||
expect(existsSync(path.join(layoutOf(main).checkoutSlot, 'lbug'))).toBe(true);
|
||||
});
|
||||
|
||||
/** Fail every rename onto one of `blocked`; others run for real. */
|
||||
const blockRenamesOnto = (blocked: ReadonlySet<string>) => {
|
||||
const realRename = fs.rename.bind(fs);
|
||||
return vi
|
||||
.spyOn(fs, 'rename')
|
||||
.mockImplementation((from, to) =>
|
||||
blocked.has(String(to))
|
||||
? Promise.reject(Object.assign(new Error('rename blocked'), { code: 'EIO' }))
|
||||
: realRename(from, to),
|
||||
);
|
||||
};
|
||||
|
||||
// #3374: the staging dir holds the checkout's only graph once putting it
|
||||
// back fails; deleting it would leave metadata at HEAD with no graph.
|
||||
it('keeps the staged graph when putting it back fails', async () => {
|
||||
const { checkouts, head } = await setup();
|
||||
const [main] = checkouts;
|
||||
const layout = layoutOf(main);
|
||||
const slot = layout.checkoutSlot;
|
||||
const meta = (await loadMeta(slot)) as RepoMeta;
|
||||
const target = commitGraphDir(layout, head, featureKeyOf(meta));
|
||||
const spy = blockRenamesOnto(new Set([target, path.join(slot, 'lbug')]));
|
||||
try {
|
||||
await publishSharedGraph(layout, main, head, () => {});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
const staging = (await fs.readdir(layout.commitsDir)).filter((n) => n.startsWith('.publish-'));
|
||||
expect(staging).toHaveLength(1);
|
||||
expect(await fs.readFile(path.join(layout.commitsDir, staging[0], 'lbug'), 'utf-8')).toBe(
|
||||
`graph from ${main}`,
|
||||
);
|
||||
expect(await listCommitDirs(layout)).toEqual([]);
|
||||
expect((await loadMeta(slot))?.graphPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops the staging dir when the graph never left the slot', async () => {
|
||||
const { checkouts, head } = await setup();
|
||||
const [main] = checkouts;
|
||||
const layout = layoutOf(main);
|
||||
const slot = layout.checkoutSlot;
|
||||
const meta = (await loadMeta(slot)) as RepoMeta;
|
||||
const target = commitGraphDir(layout, head, featureKeyOf(meta));
|
||||
const spy = blockRenamesOnto(new Set([target]));
|
||||
try {
|
||||
await publishSharedGraph(layout, main, head, () => {});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
expect(await fs.readdir(layout.commitsDir)).toEqual([]);
|
||||
expect(await fs.readFile(path.join(slot, 'lbug'), 'utf-8')).toBe(`graph from ${main}`);
|
||||
});
|
||||
|
||||
const seedFreshSlot = async (checkout: string): Promise<RepoMeta | null> => {
|
||||
const layout = layoutOf(checkout);
|
||||
await fs.rm(layout.checkoutSlot, { recursive: true, force: true });
|
||||
await seedSharedSlot(layout, checkout, () => {});
|
||||
return loadMeta(layout.checkoutSlot);
|
||||
};
|
||||
|
||||
it('seeds a pristine checkout at the graph commit as up to date', async () => {
|
||||
const { checkouts, head } = await setup();
|
||||
const [main, wt] = checkouts;
|
||||
await publishSharedGraph(layoutOf(main), main, head, () => {});
|
||||
const seeded = await seedFreshSlot(wt);
|
||||
expect(seeded?.graphPath).toBe((await loadMeta(layoutOf(main).checkoutSlot))?.graphPath);
|
||||
expect(seeded?.lastCommit).toBe(head);
|
||||
});
|
||||
|
||||
it('seeds a checkout that hides committed files without a commit, then re-points', async () => {
|
||||
const { checkouts, head } = await setup();
|
||||
const [main, wt] = checkouts;
|
||||
await publishSharedGraph(layoutOf(main), main, head, () => {});
|
||||
const shared = (await loadMeta(layoutOf(main).checkoutSlot))?.graphPath;
|
||||
git(wt, 'update-index', '--skip-worktree', '--', 'a.ts');
|
||||
const seeded = await seedFreshSlot(wt);
|
||||
expect(seeded?.graphPath).toBe(shared);
|
||||
// An empty lastCommit sends the next analyze through the file-hash diff.
|
||||
expect(seeded?.lastCommit).toBe('');
|
||||
|
||||
// That analyze copies the graph, finds nothing to change, and stamps HEAD;
|
||||
// once the checkout shows every file again, publish drops the copy.
|
||||
const slot = layoutOf(wt).checkoutSlot;
|
||||
expect(await ensurePrivateSharedGraph(slot, () => {})).toBe(true);
|
||||
const copied = await loadMeta(slot);
|
||||
expect(copied).not.toBeNull();
|
||||
await saveMeta(slot, { ...(copied as RepoMeta), lastCommit: head });
|
||||
git(wt, 'update-index', '--no-skip-worktree', '--', 'a.ts');
|
||||
await publishSharedGraph(layoutOf(wt), wt, head, () => {});
|
||||
expect((await loadMeta(slot))?.graphPath).toBe(shared);
|
||||
expect(existsSync(path.join(slot, 'lbug'))).toBe(false);
|
||||
});
|
||||
|
||||
const checkpoint: EmbeddingCheckpoint = {
|
||||
at: '2026-01-01T00:00:00.000Z',
|
||||
nodesProcessed: 1,
|
||||
totalNodes: 2,
|
||||
chunksProcessed: 1,
|
||||
model: 'm',
|
||||
dimensions: 4,
|
||||
provider: 'local',
|
||||
kind: 'partial',
|
||||
pendingNodeIds: ['n2'],
|
||||
};
|
||||
|
||||
// #3374: a graph with embeddings still owed would become every checkout's
|
||||
// graph, and its checkpoint-free copy would look complete forever.
|
||||
it('keeps a graph with pending embeddings private', async () => {
|
||||
const { checkouts, head } = await setup();
|
||||
const [main] = checkouts;
|
||||
const slot = layoutOf(main).checkoutSlot;
|
||||
const meta = (await loadMeta(slot)) as RepoMeta;
|
||||
await saveMeta(slot, { ...meta, embeddingCheckpoint: checkpoint });
|
||||
await publishSharedGraph(layoutOf(main), main, head, () => {});
|
||||
expect(await listCommitDirs(layoutOf(main))).toEqual([]);
|
||||
expect(existsSync(path.join(slot, 'lbug'))).toBe(true);
|
||||
expect((await loadMeta(slot))?.embeddingCheckpoint).toEqual(checkpoint);
|
||||
});
|
||||
|
||||
// A commit graph published before that rule may still be the weaker one;
|
||||
// the checkout keeps its own graph rather than trading down to it.
|
||||
it.each<[string, Partial<RepoMeta>]>([
|
||||
['records pending embeddings', { embeddingCheckpoint: checkpoint, stats: { embeddings: 5 } }],
|
||||
['has fewer embeddings', { stats: { embeddings: 2 } }],
|
||||
])('keeps the private graph when the published one %s', async (_label, targetDelta) => {
|
||||
const { checkouts, head } = await setup();
|
||||
const [main, wt] = checkouts;
|
||||
const layout = layoutOf(main);
|
||||
const slot = layout.checkoutSlot;
|
||||
const own: RepoMeta = { ...((await loadMeta(slot)) as RepoMeta), stats: { embeddings: 5 } };
|
||||
await saveMeta(slot, own);
|
||||
const target = commitGraphDir(layout, head, featureKeyOf(own));
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
await fs.writeFile(path.join(target, 'lbug'), 'older published graph');
|
||||
const targetMeta: Partial<RepoMeta> = {
|
||||
lastCommit: head,
|
||||
indexedAt: own.indexedAt,
|
||||
...targetDelta,
|
||||
};
|
||||
await fs.writeFile(path.join(target, 'gitnexus.json'), JSON.stringify(targetMeta));
|
||||
// Another checkout reads the published graph.
|
||||
const wtSlot = layoutOf(wt).checkoutSlot;
|
||||
await fs.rm(path.join(wtSlot, 'lbug'));
|
||||
const wtMeta = (await loadMeta(wtSlot)) as RepoMeta;
|
||||
await saveMeta(wtSlot, { ...wtMeta, graphPath: path.join(target, 'lbug') });
|
||||
await publishSharedGraph(layout, main, head, () => {});
|
||||
expect(await fs.readFile(path.join(slot, 'lbug'), 'utf-8')).toBe(`graph from ${main}`);
|
||||
expect((await loadMeta(slot))?.graphPath).toBeUndefined();
|
||||
// The published graph is immutable: other checkouts may point at it.
|
||||
expect(await fs.readFile(path.join(target, 'lbug'), 'utf-8')).toBe('older published graph');
|
||||
});
|
||||
});
|
||||
|
||||
// #3374: a publish interrupted between its renames (or a reclaimed commit
|
||||
// graph) leaves slot metadata at HEAD with no graph behind it.
|
||||
describe('up-to-date fast path over a missing shared graph (#3374)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-test-shared-missing-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-test-shared-missing-repo-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpRepo.cleanup();
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('rebuilds a slot whose metadata is at HEAD but whose graph is gone', async () => {
|
||||
const root = await fs.realpath(tmpRepo.dbPath);
|
||||
const main = path.join(root, 'main');
|
||||
await fs.mkdir(main);
|
||||
git(main, 'init', '-q', '-b', 'main');
|
||||
git(
|
||||
main,
|
||||
'-c',
|
||||
'user.name=t',
|
||||
'-c',
|
||||
'user.email=t@t',
|
||||
'commit',
|
||||
'-q',
|
||||
'--allow-empty',
|
||||
'-m',
|
||||
'init',
|
||||
);
|
||||
const wt = path.join(root, 'wt');
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt', wt);
|
||||
const slot = layoutOf(wt).checkoutSlot;
|
||||
await fs.mkdir(slot, { recursive: true });
|
||||
await saveMeta(slot, {
|
||||
repoPath: wt,
|
||||
storagePath: slot,
|
||||
lastCommit: git(wt, 'rev-parse', 'HEAD'),
|
||||
indexedAt: new Date().toISOString(),
|
||||
schemaFingerprint: SCHEMA_FINGERPRINT,
|
||||
analysisFeatures: {
|
||||
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
|
||||
},
|
||||
runnerIdentity: resolveAnalyzerRunnerIdentity(
|
||||
pathToFileURL(path.resolve(__dirname, '../../src/core/run-analyze.ts')).href,
|
||||
),
|
||||
// Same FTS mode as the run below, so only the missing graph can
|
||||
// decide against the fast path.
|
||||
capabilities: {
|
||||
graph: { provider: 'ladybugdb', status: 'available' },
|
||||
fts: { provider: 'ladybugdb-fts', status: 'unavailable', skipReason: 'disabled-by-flag' },
|
||||
vectorSearch: { provider: 'exact-scan', status: 'unavailable', exactScanLimit: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
const result = await runFullAnalysis(
|
||||
wt,
|
||||
{ skipAgentsMd: true, skipSkills: true, skipFts: true },
|
||||
{ onProgress: () => {} },
|
||||
);
|
||||
|
||||
expect(result.alreadyUpToDate).not.toBe(true);
|
||||
expect(existsSync(resolveGraphPath(slot))).toBe(true);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('listStoreMetaRoots', () => {
|
||||
let tmp: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let layout: SharedStoreLayout;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await createTempDir('gitnexus-test-store-roots-');
|
||||
const root = path.join(tmp.dbPath, 'store');
|
||||
layout = {
|
||||
key: 'repo-0000',
|
||||
root,
|
||||
cachesDir: path.join(root, 'caches'),
|
||||
commitsDir: path.join(root, 'commits'),
|
||||
checkoutsDir: path.join(root, 'checkouts'),
|
||||
checkoutSlot: path.join(root, 'checkouts', 'slot-a'),
|
||||
canonicalCheckout: null,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await tmp.cleanup();
|
||||
});
|
||||
|
||||
it('stays complete when the store directories do not exist yet', async () => {
|
||||
expect(await listStoreMetaRoots(layout)).toEqual({ roots: [], complete: true });
|
||||
});
|
||||
|
||||
it('lists checkout slots and commit graphs, skipping dot entries', async () => {
|
||||
await fs.mkdir(path.join(layout.checkoutsDir, 'slot-a'), { recursive: true });
|
||||
await fs.mkdir(path.join(layout.checkoutsDir, '.lock'), { recursive: true });
|
||||
await fs.mkdir(path.join(layout.commitsDir, 'abc'), { recursive: true });
|
||||
expect(await listStoreMetaRoots(layout)).toEqual({
|
||||
roots: [path.join(layout.checkoutsDir, 'slot-a'), path.join(layout.commitsDir, 'abc')],
|
||||
complete: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an incomplete listing when a store directory cannot be read', async () => {
|
||||
await fs.mkdir(path.join(layout.commitsDir, 'abc'), { recursive: true });
|
||||
await fs.mkdir(layout.checkoutsDir, { recursive: true });
|
||||
const realReaddir = fs.readdir;
|
||||
vi.spyOn(fs, 'readdir').mockImplementation((async (dir: string) => {
|
||||
if (dir === layout.checkoutsDir) {
|
||||
throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||
}
|
||||
return realReaddir(dir);
|
||||
}) as unknown as typeof fs.readdir);
|
||||
expect(await listStoreMetaRoots(layout)).toEqual({
|
||||
roots: [path.join(layout.commitsDir, 'abc')],
|
||||
complete: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
148
gitnexus/test/integration/shared-store-cache.test.ts
Normal file
148
gitnexus/test/integration/shared-store-cache.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { execFileSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { withStoreLock } from '../../src/storage/shared-store-lifecycle.js';
|
||||
import { loadMeta } from '../../src/storage/repo-manager.js';
|
||||
import {
|
||||
resolveSharedStore,
|
||||
sharedStoreLayout,
|
||||
type SharedStoreLayout,
|
||||
} from '../../src/storage/shared-store.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
// These suites exercise sharing; an inherited opt-out would silently disable it.
|
||||
const savedSharedStoreSwitch = process.env.GITNEXUS_SHARED_STORE;
|
||||
beforeAll(() => {
|
||||
delete process.env.GITNEXUS_SHARED_STORE;
|
||||
});
|
||||
afterAll(() => {
|
||||
if (savedSharedStoreSwitch === undefined) delete process.env.GITNEXUS_SHARED_STORE;
|
||||
else process.env.GITNEXUS_SHARED_STORE = savedSharedStoreSwitch;
|
||||
});
|
||||
|
||||
/**
|
||||
* #3352 U5 — linked worktrees keep one parse cache and ParsedFile store per
|
||||
* shared store, and one member's prune never evicts chunks another member
|
||||
* still records.
|
||||
*/
|
||||
const git = (cwd: string, ...args: string[]): string =>
|
||||
execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
||||
|
||||
const commitAll = (cwd: string, message: string): void => {
|
||||
git(cwd, 'add', '-A');
|
||||
git(cwd, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', message);
|
||||
};
|
||||
|
||||
const layoutOf = (checkout: string): SharedStoreLayout => {
|
||||
const layout = resolveSharedStore(checkout);
|
||||
expect(layout).not.toBeNull();
|
||||
return layout as SharedStoreLayout;
|
||||
};
|
||||
|
||||
const indexedCacheKeys = async (layout: SharedStoreLayout): Promise<string[]> => {
|
||||
const raw = await fs.readFile(path.join(layout.cachesDir, 'parse-cache', 'index.json'), 'utf-8');
|
||||
return (JSON.parse(raw) as { keys: string[] }).keys;
|
||||
};
|
||||
|
||||
describe('shared store caches (#3352)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
let wtA: string;
|
||||
let wtB: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-test-cache-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-test-cache-repo-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
const root = await fs.realpath(tmpRepo.dbPath);
|
||||
const main = path.join(root, 'main');
|
||||
await fs.mkdir(main);
|
||||
git(main, 'init', '-q', '-b', 'main');
|
||||
await fs.writeFile(path.join(main, 'a.ts'), 'export function alpha() { return 1; }\n');
|
||||
await fs.writeFile(path.join(main, 'b.ts'), 'export function beta() { return 2; }\n');
|
||||
commitAll(main, 'init');
|
||||
wtA = path.join(root, 'wt-a');
|
||||
wtB = path.join(root, 'wt-b');
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt-a', wtA);
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt-b', wtB);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpRepo.cleanup();
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('keeps one cache tree in the store and none in any checkout', async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(wtA, {}, { onProgress: () => {} });
|
||||
await fs.writeFile(path.join(wtB, 'c.ts'), 'export function gamma() { return 3; }\n');
|
||||
await runFullAnalysis(wtB, {}, { onProgress: () => {} });
|
||||
|
||||
const layout = layoutOf(wtA);
|
||||
expect(existsSync(path.join(layout.cachesDir, 'parse-cache'))).toBe(true);
|
||||
expect(existsSync(path.join(layout.cachesDir, 'parsedfile-cache'))).toBe(true);
|
||||
for (const checkout of [wtA, wtB]) {
|
||||
for (const dir of [layoutOf(checkout).checkoutSlot, path.join(checkout, '.gitnexus')]) {
|
||||
expect(existsSync(path.join(dir, 'parse-cache'))).toBe(false);
|
||||
expect(existsSync(path.join(dir, 'parsedfile-cache'))).toBe(false);
|
||||
}
|
||||
}
|
||||
}, 240_000);
|
||||
|
||||
it("keeps chunks another member records when one member's file set changes", async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(wtA, {}, { onProgress: () => {} });
|
||||
const layout = layoutOf(wtA);
|
||||
const commitDir = (await fs.readdir(layout.commitsDir)).find((n) => !n.startsWith('.'));
|
||||
const keysA = (await loadMeta(path.join(layout.commitsDir, commitDir as string)))?.cacheKeys;
|
||||
expect(keysA?.length).toBeGreaterThan(0);
|
||||
|
||||
await fs.rm(path.join(wtB, 'b.ts'));
|
||||
await runFullAnalysis(wtB, {}, { onProgress: () => {} });
|
||||
const keysB = (await loadMeta(layoutOf(wtB).checkoutSlot))?.cacheKeys;
|
||||
expect(keysB?.length).toBeGreaterThan(0);
|
||||
expect(keysB).not.toEqual(keysA);
|
||||
|
||||
const indexed = await indexedCacheKeys(layout);
|
||||
for (const key of [...(keysA ?? []), ...(keysB ?? [])]) expect(indexed).toContain(key);
|
||||
}, 240_000);
|
||||
});
|
||||
|
||||
describe('withStoreLock', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-test-store-lock-home-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('runs same-named sections one at a time', async () => {
|
||||
const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout');
|
||||
const events: string[] = [];
|
||||
const section = (name: string) => async () => {
|
||||
events.push(`${name}:start`);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
events.push(`${name}:end`);
|
||||
};
|
||||
await Promise.all([
|
||||
withStoreLock(layout, 'cache', section('one')),
|
||||
withStoreLock(layout, 'cache', section('two')),
|
||||
]);
|
||||
expect(events[1]).toBe(`${events[0].split(':')[0]}:end`);
|
||||
expect(events[3]).toBe(`${events[2].split(':')[0]}:end`);
|
||||
});
|
||||
});
|
||||
687
gitnexus/test/integration/shared-store-clean.test.ts
Normal file
687
gitnexus/test/integration/shared-store-clean.test.ts
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
import { execFileSync } from 'child_process';
|
||||
import { constants as fsConstants, existsSync, readFileSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
getStoragePaths,
|
||||
loadMeta,
|
||||
readRegistry,
|
||||
registerRepo,
|
||||
saveMeta,
|
||||
unregisterRepo,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import {
|
||||
resolveSharedStore,
|
||||
sharedStoreLayout,
|
||||
type SharedStoreLayout,
|
||||
} from '../../src/storage/shared-store.js';
|
||||
import {
|
||||
findLegacyLocalIndex,
|
||||
reclaimAfterSlotRemoval,
|
||||
readGraphCloneKind,
|
||||
reclaimSharedStore,
|
||||
removeCheckoutStorage,
|
||||
removeLegacyLocalIndex,
|
||||
removeSharedStorePointer,
|
||||
writeSharedStorePointer,
|
||||
} from '../../src/storage/shared-store-lifecycle.js';
|
||||
import { getGlobalDir } from '../../src/storage/global-dir.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
// These suites exercise sharing; an inherited opt-out would silently disable it.
|
||||
const savedSharedStoreSwitch = process.env.GITNEXUS_SHARED_STORE;
|
||||
beforeAll(() => {
|
||||
delete process.env.GITNEXUS_SHARED_STORE;
|
||||
});
|
||||
afterAll(() => {
|
||||
if (savedSharedStoreSwitch === undefined) delete process.env.GITNEXUS_SHARED_STORE;
|
||||
else process.env.GITNEXUS_SHARED_STORE = savedSharedStoreSwitch;
|
||||
});
|
||||
|
||||
/**
|
||||
* #3352 U6 — clean removes only what no remaining member references.
|
||||
*/
|
||||
const git = (cwd: string, ...args: string[]): string =>
|
||||
execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
||||
|
||||
const commitAll = (cwd: string, message: string): void => {
|
||||
git(cwd, 'add', '-A');
|
||||
git(cwd, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', message);
|
||||
};
|
||||
|
||||
const layoutOf = (checkout: string): SharedStoreLayout => {
|
||||
const layout = resolveSharedStore(checkout);
|
||||
expect(layout).not.toBeNull();
|
||||
return layout as SharedStoreLayout;
|
||||
};
|
||||
|
||||
const commitDirs = async (layout: SharedStoreLayout): Promise<string[]> =>
|
||||
(await fs.readdir(layout.commitsDir).catch(() => [] as string[])).filter(
|
||||
(n) => !n.startsWith('.'),
|
||||
);
|
||||
|
||||
describe('shared store clean (#3352)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
let savedCwd: string;
|
||||
let main: string;
|
||||
let wtA: string;
|
||||
let wtB: string;
|
||||
|
||||
const analyze = async (checkout: string) => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
return runFullAnalysis(checkout, {}, { onProgress: () => {} });
|
||||
};
|
||||
|
||||
const cleanIn = async (checkout: string, options: Record<string, unknown>) => {
|
||||
const { cleanCommand } = await import('../../src/cli/clean.js');
|
||||
process.chdir(checkout);
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
await cleanCommand(options);
|
||||
return log.mock.calls.map((c) => String(c[0]));
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
process.chdir(savedCwd);
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
savedCwd = process.cwd();
|
||||
tmpHome = await createTempDir('gitnexus-test-clean-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-test-clean-repo-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
const root = await fs.realpath(tmpRepo.dbPath);
|
||||
main = path.join(root, 'main');
|
||||
await fs.mkdir(main);
|
||||
git(main, 'init', '-q', '-b', 'main');
|
||||
await fs.writeFile(path.join(main, 'a.ts'), 'export function alpha() { return 1; }\n');
|
||||
commitAll(main, 'init');
|
||||
wtA = path.join(root, 'wt-a');
|
||||
wtB = path.join(root, 'wt-b');
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt-a', wtA);
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt-b', wtB);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.chdir(savedCwd);
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpRepo.cleanup();
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('Covers AE5: cleaning one of two worktrees at X keeps X for the other', async () => {
|
||||
await analyze(wtA);
|
||||
await analyze(wtB);
|
||||
const graph = getStoragePaths(wtB, undefined, layoutOf(wtB).checkoutSlot).lbugPath;
|
||||
|
||||
await cleanIn(wtA, { force: true });
|
||||
|
||||
expect(existsSync(layoutOf(wtA).checkoutSlot)).toBe(false);
|
||||
expect(existsSync(graph)).toBe(true);
|
||||
expect((await loadMeta(layoutOf(wtB).checkoutSlot))?.graphPath).toBe(graph);
|
||||
}, 240_000);
|
||||
|
||||
it('deletes the commit graph and the store when the last member is cleaned', async () => {
|
||||
await analyze(wtA);
|
||||
await analyze(wtB);
|
||||
const layout = layoutOf(wtA);
|
||||
|
||||
await cleanIn(wtA, { force: true });
|
||||
const logs = await cleanIn(wtB, { force: true });
|
||||
|
||||
expect(await commitDirs(layout)).toEqual([]);
|
||||
expect(existsSync(layout.root)).toBe(false);
|
||||
expect(logs.join('\n')).toMatch(/removed 1 commit graph/);
|
||||
}, 240_000);
|
||||
|
||||
it('clean --all --force removes each shared checkout pointer with its slot', async () => {
|
||||
await analyze(wtA);
|
||||
await analyze(wtB);
|
||||
expect(existsSync(path.join(wtA, '.gitnexus', 'store.json'))).toBe(true);
|
||||
await cleanIn(wtA, { all: true, force: true });
|
||||
for (const wt of [wtA, wtB]) {
|
||||
expect(existsSync(path.join(wt, '.gitnexus', 'store.json'))).toBe(false);
|
||||
}
|
||||
expect(existsSync(layoutOf(wtA).root)).toBe(false);
|
||||
}, 240_000);
|
||||
|
||||
it('deletes the slot directory last, after unregistering and removing the pointer', async () => {
|
||||
await analyze(wtA);
|
||||
const slot = layoutOf(wtA).checkoutSlot;
|
||||
const pointer = path.join(wtA, '.gitnexus', 'store.json');
|
||||
const registry = path.join(tmpHome.dbPath, 'registry.json');
|
||||
expect(readFileSync(registry, 'utf-8')).toContain(JSON.stringify(wtA).slice(1, -1));
|
||||
const seen: { pointer: boolean; registered: boolean }[] = [];
|
||||
const realRm = fs.rm;
|
||||
const rm = vi.spyOn(fs, 'rm').mockImplementation(async (target, options) => {
|
||||
if (String(target) === slot) {
|
||||
seen.push({
|
||||
pointer: existsSync(pointer),
|
||||
registered: readFileSync(registry, 'utf-8').includes(JSON.stringify(wtA).slice(1, -1)),
|
||||
});
|
||||
}
|
||||
return realRm(target, options);
|
||||
});
|
||||
try {
|
||||
await cleanIn(wtA, { force: true });
|
||||
} finally {
|
||||
rm.mockRestore();
|
||||
}
|
||||
expect(seen).toEqual([{ pointer: false, registered: false }]);
|
||||
expect(existsSync(slot)).toBe(false);
|
||||
}, 240_000);
|
||||
|
||||
it('previews without --force and deletes nothing', async () => {
|
||||
await analyze(wtA);
|
||||
const layout = layoutOf(wtA);
|
||||
await cleanIn(wtA, {});
|
||||
expect(existsSync(layout.checkoutSlot)).toBe(true);
|
||||
expect(await commitDirs(layout)).toHaveLength(1);
|
||||
}, 240_000);
|
||||
|
||||
it('clean --gc skips stray files in the stores directory', async () => {
|
||||
await analyze(wtA);
|
||||
await fs.writeFile(path.join(tmpHome.dbPath, 'stores', '.DS_Store'), 'x');
|
||||
const logs = await cleanIn(main, { gc: true, force: true });
|
||||
expect(logs.join('\n')).toMatch(/Shared store .*: dropped 0 checkout/);
|
||||
}, 240_000);
|
||||
|
||||
it('clean --gc does not follow a symlink in the stores directory', async () => {
|
||||
const decoy = path.join(tmpRepo.dbPath, 'decoy');
|
||||
await fs.mkdir(path.join(decoy, 'checkouts'), { recursive: true });
|
||||
await fs.mkdir(path.join(decoy, 'commits', 'ddddddd-4444444444444444'), { recursive: true });
|
||||
const stores = path.join(tmpHome.dbPath, 'stores');
|
||||
await fs.mkdir(stores, { recursive: true });
|
||||
await fs.symlink(
|
||||
decoy,
|
||||
path.join(stores, 'repo-0123456789ab'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
|
||||
await cleanIn(main, { gc: true, force: true });
|
||||
|
||||
expect(existsSync(path.join(decoy, 'commits', 'ddddddd-4444444444444444'))).toBe(true);
|
||||
}, 240_000);
|
||||
|
||||
it('clean --gc reports no stores when stores/ holds only stray files', async () => {
|
||||
await fs.mkdir(path.join(tmpHome.dbPath, 'stores'), { recursive: true });
|
||||
await fs.writeFile(path.join(tmpHome.dbPath, 'stores', '.DS_Store'), 'x');
|
||||
const logs = await cleanIn(main, { gc: true });
|
||||
expect(logs).toContain('No shared stores to collect.');
|
||||
});
|
||||
|
||||
it('clean --gc skips a store removed by a concurrent collector', async () => {
|
||||
const gone = path.join(tmpHome.dbPath, 'stores', 'repo-0123456789ab');
|
||||
await fs.mkdir(gone, { recursive: true });
|
||||
const realLstat = fs.lstat;
|
||||
const lstat = vi.spyOn(fs, 'lstat').mockImplementation((async (
|
||||
target: string,
|
||||
...rest: unknown[]
|
||||
) => {
|
||||
if (String(target) === gone) throw Object.assign(new Error('gone'), { code: 'ENOENT' });
|
||||
return (realLstat as (...a: unknown[]) => Promise<unknown>)(target, ...rest);
|
||||
}) as typeof fs.lstat);
|
||||
try {
|
||||
const logs = await cleanIn(main, { gc: true, force: true });
|
||||
expect(logs).toContain('No shared stores to collect.');
|
||||
} finally {
|
||||
lstat.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('clean --gc without --force previews and deletes nothing', async () => {
|
||||
await analyze(wtA);
|
||||
await fs.writeFile(path.join(wtB, 'b.ts'), 'export function beta() { return 2; }\n');
|
||||
commitAll(wtB, 'b');
|
||||
await analyze(wtB);
|
||||
const slotB = layoutOf(wtB).checkoutSlot;
|
||||
git(main, 'worktree', 'remove', '--force', wtB);
|
||||
|
||||
const logs = await cleanIn(main, { gc: true });
|
||||
|
||||
expect(existsSync(slotB)).toBe(true);
|
||||
expect(await commitDirs(layoutOf(wtA))).toHaveLength(2);
|
||||
expect(logs.join('\n')).toMatch(/would drop 1 checkout\(s\) and remove 1 commit graph/);
|
||||
}, 240_000);
|
||||
|
||||
it('clean --gc drops a deleted worktree and the graph only it referenced', async () => {
|
||||
await analyze(wtA);
|
||||
await fs.writeFile(path.join(wtB, 'b.ts'), 'export function beta() { return 2; }\n');
|
||||
commitAll(wtB, 'b');
|
||||
await analyze(wtB);
|
||||
const layout = layoutOf(wtB);
|
||||
const slotB = layout.checkoutSlot;
|
||||
expect(await commitDirs(layout)).toHaveLength(2);
|
||||
|
||||
git(main, 'worktree', 'remove', '--force', wtB);
|
||||
const logs = await cleanIn(main, { gc: true, force: true });
|
||||
|
||||
expect(existsSync(slotB)).toBe(false);
|
||||
expect(await commitDirs(layout)).toHaveLength(1);
|
||||
expect(existsSync(layoutOf(wtA).checkoutSlot)).toBe(true);
|
||||
expect(logs.join('\n')).toMatch(/dropped 1 checkout\(s\), removed 1 commit graph/);
|
||||
}, 240_000);
|
||||
});
|
||||
|
||||
describe('reclaimSharedStore', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-test-reclaim-home-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
const layout = (): SharedStoreLayout => sharedStoreLayout('repo-0123456789ab', '/tmp/wt');
|
||||
|
||||
const commitGraph = async (name: string): Promise<string> => {
|
||||
const dir = path.join(layout().commitsDir, name);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, 'lbug'), 'graph');
|
||||
return dir;
|
||||
};
|
||||
|
||||
const member = async (slotName: string, meta: Record<string, unknown>): Promise<string> => {
|
||||
const slot = path.join(layout().checkoutsDir, slotName);
|
||||
await fs.mkdir(slot, { recursive: true });
|
||||
await saveMeta(slot, { lastCommit: '', indexedAt: '', repoPath: '/tmp/wt', ...meta });
|
||||
return slot;
|
||||
};
|
||||
|
||||
it('keeps referenced graphs and removes unreferenced graphs and stale staging', async () => {
|
||||
const kept = await commitGraph('aaaaaaa-1111111111111111');
|
||||
const orphan = await commitGraph('bbbbbbb-2222222222222222');
|
||||
const staging = await commitGraph('.publish-dead');
|
||||
await member('wt-000000000000', { graphPath: path.join(kept, 'lbug') });
|
||||
|
||||
const result = await reclaimSharedStore(layout().root);
|
||||
|
||||
expect(existsSync(kept)).toBe(true);
|
||||
expect(existsSync(orphan)).toBe(false);
|
||||
expect(existsSync(staging)).toBe(false);
|
||||
expect(result.removed).toEqual([orphan]);
|
||||
expect(result.storeRemoved).toBe(false);
|
||||
});
|
||||
|
||||
it('never collects a slot whose metadata it cannot attribute', async () => {
|
||||
const slot = path.join(layout().checkoutsDir, 'unknown-000000000000');
|
||||
await fs.mkdir(slot, { recursive: true });
|
||||
const result = await reclaimSharedStore(layout().root, { gc: true });
|
||||
expect(existsSync(slot)).toBe(true);
|
||||
expect(result.droppedMembers).toEqual([]);
|
||||
});
|
||||
|
||||
it('is a no-op for storage outside the stores directory', async () => {
|
||||
const outside = path.join(tmpHome.dbPath, 'elsewhere', '.gitnexus');
|
||||
await fs.mkdir(outside, { recursive: true });
|
||||
expect(await reclaimAfterSlotRemoval(outside)).toBeNull();
|
||||
expect(existsSync(outside)).toBe(true);
|
||||
});
|
||||
|
||||
it('clean --gc keeps a slot whose index lock is held (analyze in progress)', async () => {
|
||||
const slot = await member('busy-000000000000', { repoPath: '/nonexistent/checkout' });
|
||||
const { acquireIndexLock } = await import('../../src/storage/index-lock.js');
|
||||
const lock = await acquireIndexLock(slot);
|
||||
try {
|
||||
const result = await reclaimSharedStore(layout().root, { gc: true });
|
||||
expect(result.droppedMembers).toEqual([]);
|
||||
expect(existsSync(slot)).toBe(true);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
const after = await reclaimSharedStore(layout().root, { gc: true });
|
||||
expect(after.droppedMembers).toEqual([slot]);
|
||||
});
|
||||
|
||||
it('clean --gc preview does not count a slot whose index lock is held', async () => {
|
||||
const slot = await member('busy-000000000000', { repoPath: '/nonexistent/checkout' });
|
||||
const { acquireIndexLock } = await import('../../src/storage/index-lock.js');
|
||||
const lock = await acquireIndexLock(slot);
|
||||
try {
|
||||
const preview = await reclaimSharedStore(layout().root, { gc: true, dryRun: true });
|
||||
expect(preview.droppedMembers).toEqual([]);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
const after = await reclaimSharedStore(layout().root, { gc: true, dryRun: true });
|
||||
expect(after.droppedMembers).toEqual([slot]);
|
||||
expect(existsSync(slot)).toBe(true);
|
||||
});
|
||||
|
||||
it("clean --gc preview leaves an orphan slot's staging files in place", async () => {
|
||||
const slot = await member('gone-000000000000', { repoPath: '/nonexistent/checkout' });
|
||||
const staging = path.join(slot, 'lbug.staging.x');
|
||||
await fs.writeFile(staging, 'partial');
|
||||
const preview = await reclaimSharedStore(layout().root, { gc: true, dryRun: true });
|
||||
expect(preview.droppedMembers).toEqual([slot]);
|
||||
expect(existsSync(staging)).toBe(true);
|
||||
});
|
||||
|
||||
it('names the leftover slot and clean --gc when the final slot deletion fails', async () => {
|
||||
const checkout = path.join(tmpHome.dbPath, 'checkout');
|
||||
await fs.mkdir(checkout);
|
||||
const slot = await member('wt-000000000000', { repoPath: checkout });
|
||||
await registerRepo(
|
||||
checkout,
|
||||
{ repoPath: checkout, storagePath: slot, lastCommit: '', indexedAt: '' },
|
||||
{ storagePath: slot },
|
||||
);
|
||||
const realRm = fs.rm;
|
||||
const rm = vi.spyOn(fs, 'rm').mockImplementation((async (
|
||||
target: string,
|
||||
...rest: unknown[]
|
||||
) => {
|
||||
if (String(target) === slot) throw Object.assign(new Error('busy'), { code: 'EBUSY' });
|
||||
return (realRm as (...a: unknown[]) => Promise<unknown>)(target, ...rest);
|
||||
}) as typeof fs.rm);
|
||||
try {
|
||||
await expect(
|
||||
removeCheckoutStorage(slot, () => unregisterRepo(checkout), checkout),
|
||||
).rejects.toThrow(/was unregistered.*gitnexus clean --gc --force/s);
|
||||
} finally {
|
||||
rm.mockRestore();
|
||||
}
|
||||
expect(await readRegistry()).toEqual([]);
|
||||
expect(existsSync(slot)).toBe(true);
|
||||
|
||||
// The leftover is now an orphan member, which `clean --gc` collects.
|
||||
const result = await reclaimSharedStore(layout().root, { gc: true });
|
||||
expect(result.droppedMembers).toEqual([slot]);
|
||||
expect(existsSync(slot)).toBe(false);
|
||||
});
|
||||
|
||||
it('counts references correctly when GITNEXUS_HOME is relative', async () => {
|
||||
const absoluteHome = process.env.GITNEXUS_HOME as string;
|
||||
process.env.GITNEXUS_HOME = path.relative(process.cwd(), absoluteHome);
|
||||
try {
|
||||
const referenced = await commitGraph('ddddddd-4444444444444444');
|
||||
await member('wt-000000000000', { graphPath: path.resolve(referenced, 'lbug') });
|
||||
// The root exactly as `clean --gc` builds it: relative under this home.
|
||||
const gcRoot = path.join(getGlobalDir(), 'stores', layout().key);
|
||||
expect(path.isAbsolute(gcRoot)).toBe(false);
|
||||
const result = await reclaimSharedStore(gcRoot);
|
||||
expect(result.removed).toEqual([]);
|
||||
expect(existsSync(referenced)).toBe(true);
|
||||
} finally {
|
||||
process.env.GITNEXUS_HOME = absoluteHome;
|
||||
}
|
||||
});
|
||||
|
||||
it('aborts instead of deleting graphs when the member list cannot be read', async () => {
|
||||
const graph = await commitGraph('eeeeeee-5555555555555555');
|
||||
await member('wt-000000000000', { graphPath: path.join(graph, 'lbug') });
|
||||
const realReaddir = fs.readdir;
|
||||
const readdir = vi.spyOn(fs, 'readdir').mockImplementation((async (
|
||||
target: string,
|
||||
...rest: unknown[]
|
||||
) => {
|
||||
if (String(target) === layout().checkoutsDir) {
|
||||
throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
||||
}
|
||||
return (realReaddir as (...a: unknown[]) => Promise<unknown>)(target, ...rest);
|
||||
}) as typeof fs.readdir);
|
||||
try {
|
||||
await expect(reclaimSharedStore(layout().root, { gc: true })).rejects.toThrow(/denied/);
|
||||
} finally {
|
||||
readdir.mockRestore();
|
||||
}
|
||||
expect(existsSync(graph)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a checkout .gitnexus directory it cannot list', async () => {
|
||||
const dir = path.join(tmpHome.dbPath, 'checkout', '.gitnexus');
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, 'store.json'), '{}');
|
||||
const realReaddir = fs.readdir;
|
||||
const readdir = vi.spyOn(fs, 'readdir').mockImplementation((async (
|
||||
target: string,
|
||||
...rest: unknown[]
|
||||
) => {
|
||||
if (String(target) === dir) throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
||||
return (realReaddir as (...a: unknown[]) => Promise<unknown>)(target, ...rest);
|
||||
}) as typeof fs.readdir);
|
||||
try {
|
||||
await removeSharedStorePointer(path.dirname(dir));
|
||||
} finally {
|
||||
readdir.mockRestore();
|
||||
}
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
expect(existsSync(path.join(dir, 'store.json'))).toBe(false);
|
||||
});
|
||||
|
||||
/** `<parent>/repo/.gitnexus -> ..`, beside a file that lives outside the checkout. */
|
||||
const symlinkedPointerDir = async (): Promise<{ checkout: string; victim: string }> => {
|
||||
const parent = path.join(tmpHome.dbPath, 'parent');
|
||||
const checkout = path.join(parent, 'repo');
|
||||
await fs.mkdir(checkout, { recursive: true });
|
||||
const victim = path.join(parent, 'a-victim.txt');
|
||||
await fs.writeFile(victim, 'keep');
|
||||
await fs.symlink('..', path.join(checkout, '.gitnexus'), 'dir');
|
||||
return { checkout, victim };
|
||||
};
|
||||
|
||||
it('writes the pointer into a real checkout .gitnexus and removes only the legacy index', async () => {
|
||||
const checkout = path.join(tmpHome.dbPath, 'checkout');
|
||||
await fs.mkdir(checkout);
|
||||
const slot = await member('wt-000000000000', { repoPath: checkout });
|
||||
await writeSharedStorePointer(checkout, layout());
|
||||
await fs.writeFile(path.join(checkout, '.gitnexus', 'lbug'), 'old graph');
|
||||
expect((await removeLegacyLocalIndex(checkout, slot))?.entries).toEqual(['lbug']);
|
||||
expect(await fs.readdir(path.join(checkout, '.gitnexus'))).toEqual(
|
||||
expect.arrayContaining(['store.json', '.gitignore']),
|
||||
);
|
||||
expect(existsSync(path.join(checkout, '.gitnexus', 'lbug'))).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a symlinked checkout .gitnexus when finding or removing a legacy index', async () => {
|
||||
const { checkout, victim } = await symlinkedPointerDir();
|
||||
const slot = await member('wt-000000000000', { repoPath: checkout });
|
||||
expect(await findLegacyLocalIndex(checkout, slot)).toBeNull();
|
||||
expect(await removeLegacyLocalIndex(checkout, slot)).toBeNull();
|
||||
expect(readFileSync(victim, 'utf-8')).toBe('keep');
|
||||
});
|
||||
|
||||
it('writes no pointer through a symlinked checkout .gitnexus', async () => {
|
||||
const { checkout } = await symlinkedPointerDir();
|
||||
await writeSharedStorePointer(checkout, layout());
|
||||
expect(existsSync(path.join(path.dirname(checkout), 'store.json'))).toBe(false);
|
||||
expect(existsSync(path.join(path.dirname(checkout), '.gitignore'))).toBe(false);
|
||||
});
|
||||
|
||||
it('removes nothing through a symlinked checkout .gitnexus', async () => {
|
||||
const { checkout, victim } = await symlinkedPointerDir();
|
||||
const outsidePointer = path.join(path.dirname(checkout), 'store.json');
|
||||
await fs.writeFile(outsidePointer, '{}');
|
||||
await removeSharedStorePointer(checkout);
|
||||
expect(existsSync(outsidePointer)).toBe(true);
|
||||
expect(readFileSync(victim, 'utf-8')).toBe('keep');
|
||||
});
|
||||
|
||||
it('aborts instead of collecting members when the registry cannot be read', async () => {
|
||||
const slot = await member('wt-000000000000', { repoPath: tmpHome.dbPath });
|
||||
await fs.writeFile(path.join(tmpHome.dbPath, 'registry.json'), '{not json');
|
||||
await expect(reclaimSharedStore(layout().root, { gc: true })).rejects.toThrow();
|
||||
expect(existsSync(slot)).toBe(true);
|
||||
});
|
||||
|
||||
it('without a registry file collects only members whose checkout is gone', async () => {
|
||||
const live = await member('wt-000000000000', { repoPath: tmpHome.dbPath });
|
||||
const dead = await member('wt-111111111111', {
|
||||
repoPath: path.join(tmpHome.dbPath, 'deleted-worktree'),
|
||||
});
|
||||
await reclaimSharedStore(layout().root, { gc: true });
|
||||
expect(existsSync(live)).toBe(true);
|
||||
expect(existsSync(dead)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a graph it cannot delete instead of failing', async () => {
|
||||
const orphan = await commitGraph('ccccccc-3333333333333333');
|
||||
await member('wt-000000000000', {});
|
||||
const rm = vi.spyOn(fs, 'rm').mockImplementation(async (target) => {
|
||||
if (String(target) === orphan) throw Object.assign(new Error('busy'), { code: 'EBUSY' });
|
||||
});
|
||||
try {
|
||||
const result = await reclaimSharedStore(layout().root);
|
||||
expect(result.kept).toEqual([orphan]);
|
||||
expect(result.removed).toEqual([]);
|
||||
} finally {
|
||||
rm.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// A torn or unreadable gitnexus.json must not read as "references nothing":
|
||||
// the graph it names would be deleted while the checkout still uses it.
|
||||
it.each([
|
||||
['invalid JSON', (file: string) => fs.writeFile(file, '{"graphPath": "/trunc')],
|
||||
['an unreadable file', (file: string) => fs.mkdir(file)],
|
||||
])('aborts instead of deleting graphs when a member has %s as metadata', async (_, corrupt) => {
|
||||
const graph = await commitGraph('fffffff-6666666666666666');
|
||||
const slot = path.join(layout().checkoutsDir, 'wt-000000000000');
|
||||
await fs.mkdir(slot, { recursive: true });
|
||||
await corrupt(path.join(slot, 'gitnexus.json'));
|
||||
await member('wt-111111111111', {});
|
||||
|
||||
await expect(reclaimSharedStore(layout().root)).rejects.toThrow(/wt-000000000000/);
|
||||
expect(existsSync(graph)).toBe(true);
|
||||
});
|
||||
|
||||
it('counts a member with no metadata as referencing nothing', async () => {
|
||||
const graph = await commitGraph('fffffff-6666666666666666');
|
||||
const slot = path.join(layout().checkoutsDir, 'wt-000000000000');
|
||||
await fs.mkdir(slot, { recursive: true });
|
||||
|
||||
const result = await reclaimSharedStore(layout().root);
|
||||
|
||||
expect(result.removed).toEqual([graph]);
|
||||
expect(existsSync(graph)).toBe(false);
|
||||
});
|
||||
|
||||
it('clean --gc keeps a member it cannot delete and still collects every store', async () => {
|
||||
const gone = '/nonexistent/checkout';
|
||||
const stuckGraph = await commitGraph('aaaaaaa-1111111111111111');
|
||||
const unreferenced = await commitGraph('bbbbbbb-2222222222222222');
|
||||
const stuck = await member('wt-000000000000', {
|
||||
repoPath: gone,
|
||||
graphPath: path.join(stuckGraph, 'lbug'),
|
||||
});
|
||||
const dropped = await member('wt-111111111111', { repoPath: gone });
|
||||
const other = sharedStoreLayout('repo-fedcba987654', '/tmp/wt');
|
||||
const otherGraph = path.join(other.commitsDir, 'ccccccc-3333333333333333');
|
||||
await fs.mkdir(otherGraph, { recursive: true });
|
||||
const otherSlot = path.join(other.checkoutsDir, 'wt-000000000000');
|
||||
await fs.mkdir(otherSlot, { recursive: true });
|
||||
await saveMeta(otherSlot, { lastCommit: '', indexedAt: '', repoPath: gone });
|
||||
|
||||
const realRm = fs.rm;
|
||||
const rm = vi.spyOn(fs, 'rm').mockImplementation((async (
|
||||
target: string,
|
||||
...rest: unknown[]
|
||||
) => {
|
||||
if (String(target) === stuck) throw Object.assign(new Error('busy'), { code: 'EBUSY' });
|
||||
return (realRm as (...a: unknown[]) => Promise<unknown>)(target, ...rest);
|
||||
}) as typeof fs.rm);
|
||||
const { cleanCommand } = await import('../../src/cli/clean.js');
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
let lines: string[];
|
||||
try {
|
||||
await cleanCommand({ gc: true, force: true });
|
||||
lines = log.mock.calls.map((c) => String(c[0]));
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
rm.mockRestore();
|
||||
}
|
||||
|
||||
// The stuck member stays a member, so the graph it names stays live.
|
||||
expect(existsSync(stuck)).toBe(true);
|
||||
expect(existsSync(stuckGraph)).toBe(true);
|
||||
expect(existsSync(dropped)).toBe(false);
|
||||
expect(existsSync(unreferenced)).toBe(false);
|
||||
// The store after it was still collected, down to its root.
|
||||
expect(existsSync(other.root)).toBe(false);
|
||||
expect(lines).toEqual(
|
||||
expect.arrayContaining([expect.stringMatching(/kept 1 checkout\(s\) it could not delete/)]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('private graph copies (#3352)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-test-clone-kind-home-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
const pointerSlot = async (): Promise<string> => {
|
||||
const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/wt');
|
||||
const graph = path.join(layout.commitsDir, 'aaaaaaa-1111111111111111');
|
||||
await fs.mkdir(graph, { recursive: true });
|
||||
await fs.writeFile(path.join(graph, 'lbug'), 'graph');
|
||||
await fs.mkdir(layout.checkoutSlot, { recursive: true });
|
||||
await saveMeta(layout.checkoutSlot, {
|
||||
lastCommit: 'aaaaaaa',
|
||||
indexedAt: '',
|
||||
repoPath: '/tmp/wt',
|
||||
graphPath: path.join(graph, 'lbug'),
|
||||
});
|
||||
return layout.checkoutSlot;
|
||||
};
|
||||
|
||||
// Stand in for the filesystem: FICLONE_FORCE succeeds only when `cow` is set.
|
||||
const fakeCopyFile = (cow: boolean) => {
|
||||
const realCopyFile = fs.copyFile;
|
||||
vi.spyOn(fs, 'copyFile').mockImplementation(async (src, dest, mode) => {
|
||||
if (mode === fsConstants.COPYFILE_FICLONE_FORCE && !cow) {
|
||||
throw Object.assign(new Error('not supported'), { code: 'ENOTSUP' });
|
||||
}
|
||||
return realCopyFile(src, dest);
|
||||
});
|
||||
};
|
||||
|
||||
it('records a copy-on-write clone', async () => {
|
||||
const slot = await pointerSlot();
|
||||
fakeCopyFile(true);
|
||||
const { ensurePrivateSharedGraph } = await import('../../src/core/shared-store-analyze.js');
|
||||
expect(await ensurePrivateSharedGraph(slot, () => {})).toBe(true);
|
||||
expect(await readGraphCloneKind(slot)).toBe('copy-on-write');
|
||||
expect(await fs.readFile(path.join(slot, 'lbug'), 'utf-8')).toBe('graph');
|
||||
});
|
||||
|
||||
it('falls back to a full copy and records it', async () => {
|
||||
const slot = await pointerSlot();
|
||||
fakeCopyFile(false);
|
||||
const { ensurePrivateSharedGraph } = await import('../../src/core/shared-store-analyze.js');
|
||||
expect(await ensurePrivateSharedGraph(slot, () => {})).toBe(true);
|
||||
expect(await readGraphCloneKind(slot)).toBe('copy');
|
||||
expect(await fs.readFile(path.join(slot, 'lbug'), 'utf-8')).toBe('graph');
|
||||
});
|
||||
|
||||
it('forgets the record when the private graph is rebuilt instead of copied', async () => {
|
||||
const slot = await pointerSlot();
|
||||
await fs.writeFile(path.join(slot, 'graph-clone'), 'copy');
|
||||
const { ensurePrivateSharedGraph } = await import('../../src/core/shared-store-analyze.js');
|
||||
await ensurePrivateSharedGraph(slot, () => {}, { copy: false });
|
||||
expect(await readGraphCloneKind(slot)).toBeNull();
|
||||
});
|
||||
});
|
||||
334
gitnexus/test/integration/shared-store-clone-optin.test.ts
Normal file
334
gitnexus/test/integration/shared-store-clone-optin.test.ts
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
import { execFileSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { resolveOptedInStore } from '../../src/core/shared-store-analyze.js';
|
||||
import { getRemoteUrl } from '../../src/storage/git.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
listRegisteredRepos,
|
||||
registerRepo,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import {
|
||||
cloneStoreKey,
|
||||
resolveSharedStore,
|
||||
sharedStoreLayout,
|
||||
type SharedStoreLayout,
|
||||
} from '../../src/storage/shared-store.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
// These suites exercise sharing; an inherited opt-out would silently disable it.
|
||||
const savedSharedStoreSwitch = process.env.GITNEXUS_SHARED_STORE;
|
||||
beforeAll(() => {
|
||||
delete process.env.GITNEXUS_SHARED_STORE;
|
||||
});
|
||||
afterAll(() => {
|
||||
if (savedSharedStoreSwitch === undefined) delete process.env.GITNEXUS_SHARED_STORE;
|
||||
else process.env.GITNEXUS_SHARED_STORE = savedSharedStoreSwitch;
|
||||
});
|
||||
|
||||
/**
|
||||
* #3352 U7 — an independent clone joins the store of a registered sibling
|
||||
* clone (same normalized origin URL) automatically, or the member named by
|
||||
* `--share-with`; `--no-share` leaves and stays out.
|
||||
*/
|
||||
const git = (cwd: string, ...args: string[]): string =>
|
||||
execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
||||
|
||||
const REMOTE = 'https://example.com/acme/widgets';
|
||||
|
||||
describe('shared store clone sharing (#3352)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
let root: string;
|
||||
let main: string;
|
||||
let wt: string;
|
||||
let storeLayout: SharedStoreLayout;
|
||||
|
||||
const analyze = async (checkout: string, options: Record<string, unknown> = {}) => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
return runFullAnalysis(checkout, options, { onProgress: () => {} });
|
||||
};
|
||||
|
||||
const cloneWithRemote = (name: string, remote: string): string => {
|
||||
const clone = path.join(root, name);
|
||||
git(root, 'clone', '-q', main, clone);
|
||||
git(clone, 'remote', 'set-url', 'origin', remote);
|
||||
return clone;
|
||||
};
|
||||
|
||||
const registeredStorage = async (checkout: string): Promise<string | undefined> =>
|
||||
(await listRegisteredRepos()).find((e) => e.path === checkout)?.storagePath;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-test-optin-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-test-optin-repo-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
root = await fs.realpath(tmpRepo.dbPath);
|
||||
main = path.join(root, 'main');
|
||||
await fs.mkdir(main);
|
||||
git(main, 'init', '-q', '-b', 'main');
|
||||
git(main, 'remote', 'add', 'origin', `${REMOTE}.git`);
|
||||
await fs.writeFile(path.join(main, 'a.ts'), 'export function alpha() { return 1; }\n');
|
||||
git(main, 'add', '-A');
|
||||
git(main, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'init');
|
||||
wt = path.join(root, 'wt');
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt', wt);
|
||||
await analyze(wt);
|
||||
storeLayout = resolveSharedStore(wt) as SharedStoreLayout;
|
||||
expect(storeLayout).not.toBeNull();
|
||||
}, 240_000);
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpRepo.cleanup();
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('a clone joins the store of a registered sibling and reuses the commit graph', async () => {
|
||||
const clone = cloneWithRemote('clone', REMOTE);
|
||||
const result = await analyze(clone);
|
||||
|
||||
const slot = await registeredStorage(clone);
|
||||
expect(path.dirname(slot as string)).toBe(storeLayout.checkoutsDir);
|
||||
expect(result.alreadyUpToDate).toBe(true);
|
||||
expect(getStoragePaths(clone, undefined, slot).lbugPath).toBe(
|
||||
getStoragePaths(wt, undefined, storeLayout.checkoutSlot).lbugPath,
|
||||
);
|
||||
expect(existsSync(path.join(clone, '.gitnexus', 'lbug'))).toBe(false);
|
||||
}, 240_000);
|
||||
|
||||
it('Covers AE7: a clone with no registered sibling keeps its own .gitnexus', async () => {
|
||||
const clone = cloneWithRemote('clone', 'https://example.com/acme/gadgets');
|
||||
await analyze(clone);
|
||||
expect(await registeredStorage(clone)).toBe(path.join(clone, '.gitnexus'));
|
||||
expect(existsSync(path.join(clone, '.gitnexus', 'lbug'))).toBe(true);
|
||||
}, 240_000);
|
||||
|
||||
it('two standalone clones of one repository found a store and share its graph', async () => {
|
||||
const solo = 'https://example.com/acme/solo';
|
||||
const first = cloneWithRemote('first', solo);
|
||||
await analyze(first);
|
||||
expect(await registeredStorage(first)).toBe(path.join(first, '.gitnexus'));
|
||||
|
||||
const second = cloneWithRemote('second', solo);
|
||||
await analyze(second);
|
||||
const secondSlot = (await registeredStorage(second)) as string;
|
||||
const checkouts = path.dirname(secondSlot);
|
||||
expect(path.basename(checkouts)).toBe('checkouts');
|
||||
expect(checkouts).not.toBe(storeLayout.checkoutsDir);
|
||||
|
||||
// The first clone joins on its next analyze and reads the same graph.
|
||||
await analyze(first);
|
||||
const firstSlot = (await registeredStorage(first)) as string;
|
||||
expect(path.dirname(firstSlot)).toBe(checkouts);
|
||||
expect(getStoragePaths(first, undefined, firstSlot).lbugPath).toBe(
|
||||
getStoragePaths(second, undefined, secondSlot).lbugPath,
|
||||
);
|
||||
// Adoption leaves the old repository-local index in place.
|
||||
expect(existsSync(path.join(first, '.gitnexus', 'lbug'))).toBe(true);
|
||||
}, 240_000);
|
||||
|
||||
it('joins the store with --share-with and reuses the commit graph at its HEAD', async () => {
|
||||
const clone = cloneWithRemote('clone', REMOTE);
|
||||
const result = await analyze(clone, { shareWith: wt });
|
||||
|
||||
const slot = await registeredStorage(clone);
|
||||
expect(path.dirname(slot as string)).toBe(storeLayout.checkoutsDir);
|
||||
expect(result.alreadyUpToDate).toBe(true);
|
||||
expect(getStoragePaths(clone, undefined, slot).lbugPath).toBe(
|
||||
getStoragePaths(wt, undefined, storeLayout.checkoutSlot).lbugPath,
|
||||
);
|
||||
expect(existsSync(path.join(clone, '.gitnexus', 'lbug'))).toBe(false);
|
||||
|
||||
// Remembered: a later plain analyze stays in the store.
|
||||
await analyze(clone);
|
||||
expect(await registeredStorage(clone)).toBe(slot);
|
||||
}, 240_000);
|
||||
|
||||
it('admits a clone whose remote differs only by embedded credentials', async () => {
|
||||
const clone = cloneWithRemote('clone', 'https://user:secret@example.com/acme/widgets.git');
|
||||
await analyze(clone, { shareWith: wt });
|
||||
expect(path.dirname((await registeredStorage(clone)) as string)).toBe(storeLayout.checkoutsDir);
|
||||
}, 240_000);
|
||||
|
||||
it('Covers AE4: refuses a clone of a different repository and changes nothing', async () => {
|
||||
const clone = cloneWithRemote('other', 'https://example.com/acme/gadgets');
|
||||
await expect(analyze(clone, { shareWith: wt })).rejects.toThrow(
|
||||
/remote URL mismatch — this checkout is "https:\/\/example\.com\/acme\/gadgets"/,
|
||||
);
|
||||
expect(await registeredStorage(clone)).toBeUndefined();
|
||||
expect(existsSync(path.join(clone, '.gitnexus', 'lbug'))).toBe(false);
|
||||
}, 240_000);
|
||||
|
||||
it('refuses a clone with no origin remote', async () => {
|
||||
const clone = cloneWithRemote('noremote', REMOTE);
|
||||
git(clone, 'remote', 'remove', 'origin');
|
||||
await expect(analyze(clone, { shareWith: wt })).rejects.toThrow(/\(no origin remote\)/);
|
||||
}, 240_000);
|
||||
|
||||
it('refuses a --share-with target that is not in a shared store', async () => {
|
||||
const plain = cloneWithRemote('plain', REMOTE);
|
||||
await analyze(plain, { noShare: true });
|
||||
const clone = cloneWithRemote('clone', REMOTE);
|
||||
await expect(analyze(clone, { shareWith: plain })).rejects.toThrow(
|
||||
/does not use a shared index store/,
|
||||
);
|
||||
}, 240_000);
|
||||
|
||||
it('--no-share moves a clone back to its own .gitnexus and keeps shared graphs', async () => {
|
||||
const clone = cloneWithRemote('clone', REMOTE);
|
||||
await analyze(clone, { shareWith: wt });
|
||||
const slot = (await registeredStorage(clone)) as string;
|
||||
|
||||
await analyze(clone, { noShare: true });
|
||||
|
||||
expect(await registeredStorage(clone)).toBe(path.join(clone, '.gitnexus'));
|
||||
expect(existsSync(slot)).toBe(false);
|
||||
expect(existsSync(getStoragePaths(wt, undefined, storeLayout.checkoutSlot).lbugPath)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// The opt-out sticks: a plain analyze does not rejoin the sibling store.
|
||||
await analyze(clone);
|
||||
expect(await registeredStorage(clone)).toBe(path.join(clone, '.gitnexus'));
|
||||
|
||||
// --share-with clears it: the clone is back in, and stays in.
|
||||
await analyze(clone, { shareWith: wt });
|
||||
await analyze(clone);
|
||||
expect(path.dirname((await registeredStorage(clone)) as string)).toBe(storeLayout.checkoutsDir);
|
||||
}, 240_000);
|
||||
|
||||
it('--no-share on an up-to-date local index still re-registers there', async () => {
|
||||
const clone = cloneWithRemote('clone', REMOTE);
|
||||
await analyze(clone, { noShare: true });
|
||||
await analyze(clone, { shareWith: wt });
|
||||
const slot = (await registeredStorage(clone)) as string;
|
||||
|
||||
const result = await analyze(clone, { noShare: true });
|
||||
|
||||
expect(result.alreadyUpToDate).toBe(true);
|
||||
expect(await registeredStorage(clone)).toBe(path.join(clone, '.gitnexus'));
|
||||
expect(existsSync(slot)).toBe(false);
|
||||
}, 240_000);
|
||||
|
||||
it('clean --gc keeps an opted-in clone that is still registered at its slot', async () => {
|
||||
const clone = cloneWithRemote('clone', REMOTE);
|
||||
await analyze(clone, { shareWith: wt });
|
||||
const slot = (await registeredStorage(clone)) as string;
|
||||
const { reclaimSharedStore } = await import('../../src/storage/shared-store-lifecycle.js');
|
||||
const result = await reclaimSharedStore(storeLayout.root, { gc: true });
|
||||
expect(result.droppedMembers).toEqual([]);
|
||||
expect(existsSync(slot)).toBe(true);
|
||||
}, 240_000);
|
||||
|
||||
it('rejects --no-share in a linked worktree', async () => {
|
||||
const before = await fs.readFile(path.join(storeLayout.checkoutSlot, 'gitnexus.json'), 'utf-8');
|
||||
await expect(analyze(wt, { noShare: true })).rejects.toThrow(/GITNEXUS_SHARED_STORE=off/);
|
||||
// Rejected before any work: no local index, slot metadata untouched.
|
||||
expect(existsSync(path.join(wt, '.gitnexus', 'lbug'))).toBe(false);
|
||||
expect(await fs.readFile(path.join(storeLayout.checkoutSlot, 'gitnexus.json'), 'utf-8')).toBe(
|
||||
before,
|
||||
);
|
||||
}, 240_000);
|
||||
});
|
||||
|
||||
/**
|
||||
* #3374: registered sibling clones that found a store at the same time
|
||||
* must pick the same key, or each keeps its own store forever. Resolves the
|
||||
* store directly (no analyze): only the registry and the clones' remotes matter.
|
||||
*/
|
||||
describe('shared store founder key for concurrent sibling clones (#3374)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
let root: string;
|
||||
|
||||
/** Clone `source` as `name` and register it at local storage, or at `storeKey`'s slot. */
|
||||
const cloneAndRegister = async (name: string, storeKey?: string): Promise<string> => {
|
||||
const clone = path.join(root, name);
|
||||
git(root, 'clone', '-q', path.join(root, 'source'), clone);
|
||||
git(clone, 'remote', 'set-url', 'origin', REMOTE);
|
||||
const storagePath = storeKey ? sharedStoreLayout(storeKey, clone).checkoutSlot : undefined;
|
||||
await registerRepo(
|
||||
clone,
|
||||
{
|
||||
repoPath: clone,
|
||||
storagePath,
|
||||
lastCommit: git(clone, 'rev-parse', 'HEAD'),
|
||||
indexedAt: new Date(0).toISOString(),
|
||||
remoteUrl: getRemoteUrl(clone),
|
||||
},
|
||||
storagePath ? { storagePath } : undefined,
|
||||
);
|
||||
return clone;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-test-founder-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-test-founder-repo-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
root = await fs.realpath(tmpRepo.dbPath);
|
||||
const source = path.join(root, 'source');
|
||||
await fs.mkdir(source);
|
||||
git(source, 'init', '-q', '-b', 'main');
|
||||
await fs.writeFile(path.join(source, 'a.ts'), 'export const a = 1;\n');
|
||||
git(source, 'add', '-A');
|
||||
git(source, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'init');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpRepo.cleanup();
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('two registered local clones resolve the same new store, keyed on the first path', async () => {
|
||||
// Registered in reverse path order so registry order cannot pick the key.
|
||||
const second = await cloneAndRegister('zeta');
|
||||
const first = await cloneAndRegister('alpha');
|
||||
|
||||
const fromFirst = await resolveOptedInStore(first, undefined);
|
||||
const fromSecond = await resolveOptedInStore(second, undefined);
|
||||
|
||||
expect(fromFirst?.key).toBe(cloneStoreKey(first));
|
||||
expect(fromSecond?.key).toBe(cloneStoreKey(first));
|
||||
});
|
||||
|
||||
it('a later clone joins the existing store rather than founding one', async () => {
|
||||
const existing = cloneStoreKey(path.join(root, 'zz-founder'));
|
||||
await cloneAndRegister('member', existing);
|
||||
await cloneAndRegister('other');
|
||||
|
||||
const joiner = await cloneAndRegister('aaa-joiner');
|
||||
expect((await resolveOptedInStore(joiner, undefined))?.key).toBe(existing);
|
||||
});
|
||||
|
||||
// #3374: `getRemoteUrl` answers from any subdirectory, so only the
|
||||
// tree-root gate keeps `analyze --skip-git <clone>/pkg` out of the store.
|
||||
it('a subdirectory of a clone with a registered sibling neither joins nor founds a store', async () => {
|
||||
await cloneAndRegister('member', cloneStoreKey(path.join(root, 'zz-founder')));
|
||||
const clone = await cloneAndRegister('other');
|
||||
const subdir = path.join(clone, 'pkg');
|
||||
await fs.mkdir(subdir);
|
||||
|
||||
expect(await resolveOptedInStore(subdir, undefined)).toBeUndefined();
|
||||
expect(await resolveOptedInStore(clone, undefined)).toBeDefined();
|
||||
});
|
||||
|
||||
it('--share-with refuses a subdirectory of a clone', async () => {
|
||||
const member = await cloneAndRegister('member', cloneStoreKey(path.join(root, 'zz-founder')));
|
||||
const subdir = path.join(await cloneAndRegister('other'), 'pkg');
|
||||
await fs.mkdir(subdir);
|
||||
|
||||
await expect(resolveOptedInStore(subdir, member)).rejects.toThrow(
|
||||
/--share-with: .* is not the root of a git checkout/,
|
||||
);
|
||||
});
|
||||
});
|
||||
291
gitnexus/test/integration/shared-store-seed.test.ts
Normal file
291
gitnexus/test/integration/shared-store-seed.test.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
import { execFileSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ensurePrivateSharedGraph } from '../../src/core/shared-store-analyze.js';
|
||||
import { getStoragePaths, loadMeta, saveMeta } from '../../src/storage/repo-manager.js';
|
||||
import { resolveSharedStore, type SharedStoreLayout } from '../../src/storage/shared-store.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
// These suites exercise sharing; an inherited opt-out would silently disable it.
|
||||
const savedSharedStoreSwitch = process.env.GITNEXUS_SHARED_STORE;
|
||||
beforeAll(() => {
|
||||
delete process.env.GITNEXUS_SHARED_STORE;
|
||||
});
|
||||
afterAll(() => {
|
||||
if (savedSharedStoreSwitch === undefined) delete process.env.GITNEXUS_SHARED_STORE;
|
||||
else process.env.GITNEXUS_SHARED_STORE = savedSharedStoreSwitch;
|
||||
});
|
||||
|
||||
/**
|
||||
* #3352 U4 — a checkout that needs its own graph is seeded from the nearest
|
||||
* commit graph and updated incrementally instead of rebuilt from scratch.
|
||||
*/
|
||||
const git = (cwd: string, ...args: string[]): string =>
|
||||
execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
||||
|
||||
const commitAll = (cwd: string, message: string): void => {
|
||||
git(cwd, 'add', '-A');
|
||||
git(cwd, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', message);
|
||||
};
|
||||
|
||||
const layoutOf = (checkout: string): SharedStoreLayout => {
|
||||
const layout = resolveSharedStore(checkout);
|
||||
expect(layout).not.toBeNull();
|
||||
return layout as SharedStoreLayout;
|
||||
};
|
||||
|
||||
const queryNames = async (graph: string): Promise<string[]> => {
|
||||
const lbug = (await import('@ladybugdb/core')).default;
|
||||
const db = new lbug.Database(graph, 0, true, true);
|
||||
const conn = new lbug.Connection(db);
|
||||
try {
|
||||
const rows = (await (
|
||||
await conn.query('MATCH (f:Function) RETURN f.name AS n ORDER BY n')
|
||||
).getAll()) as { n: string }[];
|
||||
return rows.map((r) => r.n);
|
||||
} finally {
|
||||
await conn.close();
|
||||
await db.close();
|
||||
}
|
||||
};
|
||||
|
||||
const graphOf = (checkout: string): string =>
|
||||
getStoragePaths(checkout, undefined, layoutOf(checkout).checkoutSlot).lbugPath;
|
||||
|
||||
describe('shared store seeding (#3352)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
let root: string;
|
||||
let main: string;
|
||||
|
||||
const analyze = async (checkout: string, logs?: string[]) => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
return runFullAnalysis(checkout, {}, { onProgress: () => {}, onLog: (m) => logs?.push(m) });
|
||||
};
|
||||
|
||||
const addWorktree = (name: string, base = 'main'): string => {
|
||||
const wt = path.join(root, name);
|
||||
git(main, 'worktree', 'add', '-q', '-b', name, wt, base);
|
||||
return wt;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-test-seed-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-test-seed-repo-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
root = await fs.realpath(tmpRepo.dbPath);
|
||||
main = path.join(root, 'main');
|
||||
await fs.mkdir(main);
|
||||
git(main, 'init', '-q', '-b', 'main');
|
||||
await fs.writeFile(path.join(main, 'a.ts'), 'export function alpha() { return 1; }\n');
|
||||
await fs.writeFile(path.join(main, 'b.ts'), 'export function beta() { return 2; }\n');
|
||||
commitAll(main, 'init');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpRepo.cleanup();
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('Covers AE2: an edited worktree gets a private incremental graph; siblings are unchanged', async () => {
|
||||
const wtA = addWorktree('wt-a');
|
||||
const wtB = addWorktree('wt-b');
|
||||
await analyze(main);
|
||||
await analyze(wtA);
|
||||
await analyze(wtB);
|
||||
const shared = graphOf(wtB);
|
||||
expect(graphOf(wtA)).toBe(shared);
|
||||
|
||||
await fs.writeFile(path.join(wtA, 'a.ts'), 'export function alphaEdited() { return 1; }\n');
|
||||
const logs: string[] = [];
|
||||
await analyze(wtA, logs);
|
||||
|
||||
expect(graphOf(wtA)).toBe(path.join(layoutOf(wtA).checkoutSlot, 'lbug'));
|
||||
expect(logs.some((m) => m.startsWith('Incremental:'))).toBe(true);
|
||||
expect(await queryNames(graphOf(wtA))).toEqual(['alphaEdited', 'beta']);
|
||||
expect(graphOf(wtB)).toBe(shared);
|
||||
expect(await queryNames(shared)).toEqual(['alpha', 'beta']);
|
||||
}, 240_000);
|
||||
|
||||
it('Covers AE3: committing the edits publishes a commit graph and drops the private one', async () => {
|
||||
const wtA = addWorktree('wt-a');
|
||||
await analyze(wtA);
|
||||
await fs.writeFile(path.join(wtA, 'c.ts'), 'export function gamma() { return 3; }\n');
|
||||
await analyze(wtA);
|
||||
const slot = layoutOf(wtA).checkoutSlot;
|
||||
expect(existsSync(path.join(slot, 'lbug'))).toBe(true);
|
||||
|
||||
commitAll(wtA, 'gamma');
|
||||
await analyze(wtA);
|
||||
|
||||
expect(existsSync(path.join(slot, 'lbug'))).toBe(false);
|
||||
expect(path.dirname(path.dirname(graphOf(wtA)))).toBe(layoutOf(wtA).commitsDir);
|
||||
expect(await queryNames(graphOf(wtA))).toEqual(['alpha', 'beta', 'gamma']);
|
||||
}, 240_000);
|
||||
|
||||
it('seeds a new worktree at a descendant commit from its indexed ancestor', async () => {
|
||||
await analyze(main);
|
||||
git(main, 'checkout', '-q', '-b', 'next');
|
||||
await fs.writeFile(path.join(main, 'c.ts'), 'export function gamma() { return 3; }\n');
|
||||
commitAll(main, 'gamma');
|
||||
git(main, 'checkout', '-q', 'main');
|
||||
const wt = addWorktree('wt-next', 'next');
|
||||
|
||||
const logs: string[] = [];
|
||||
await analyze(wt, logs);
|
||||
|
||||
// `main` was indexed before any worktree existed, so its index is
|
||||
// repository-local; the first worktree seeds from that copy.
|
||||
expect(logs).toContain(
|
||||
`Shared store: seeded from the local index at ${path.join(main, '.gitnexus')}.`,
|
||||
);
|
||||
expect(logs.some((m) => m.startsWith('Incremental:'))).toBe(true);
|
||||
expect(await queryNames(graphOf(wt))).toEqual(['alpha', 'beta', 'gamma']);
|
||||
// The source index is left in place.
|
||||
expect(existsSync(path.join(main, '.gitnexus', 'lbug'))).toBe(true);
|
||||
}, 240_000);
|
||||
|
||||
it('seeds a descendant worktree from the store commit graph once one exists', async () => {
|
||||
const wtA = addWorktree('wt-a');
|
||||
await analyze(wtA);
|
||||
git(main, 'checkout', '-q', '-b', 'next');
|
||||
await fs.writeFile(path.join(main, 'c.ts'), 'export function gamma() { return 3; }\n');
|
||||
commitAll(main, 'gamma');
|
||||
git(main, 'checkout', '-q', 'main');
|
||||
const wt = addWorktree('wt-next', 'next');
|
||||
|
||||
const logs: string[] = [];
|
||||
await analyze(wt, logs);
|
||||
|
||||
const base = git(main, 'rev-parse', 'main');
|
||||
expect(logs).toContain(`Shared store: seeded from commit graph ${base.slice(0, 12)}.`);
|
||||
expect(logs.some((m) => m.startsWith('Incremental:'))).toBe(true);
|
||||
expect(await queryNames(graphOf(wt))).toEqual(['alpha', 'beta', 'gamma']);
|
||||
}, 240_000);
|
||||
|
||||
it('runs a full build for a worktree with no indexed ancestor', async () => {
|
||||
await analyze(main);
|
||||
const orphan = path.join(root, 'orphan');
|
||||
git(main, 'worktree', 'add', '-q', '--detach', orphan);
|
||||
git(orphan, 'checkout', '-q', '--orphan', 'unrelated');
|
||||
git(orphan, 'rm', '-q', '-rf', '.');
|
||||
await fs.writeFile(path.join(orphan, 'z.ts'), 'export function zeta() { return 0; }\n');
|
||||
commitAll(orphan, 'unrelated root');
|
||||
|
||||
const logs: string[] = [];
|
||||
await analyze(orphan, logs);
|
||||
|
||||
expect(logs.some((m) => m.startsWith('Shared store: seeded'))).toBe(false);
|
||||
expect(logs.some((m) => m.startsWith('Incremental:'))).toBe(false);
|
||||
expect(await queryNames(graphOf(orphan))).toEqual(['zeta']);
|
||||
}, 240_000);
|
||||
|
||||
it('a forced rebuild of a pointer slot builds without copying the shared graph', async () => {
|
||||
const wt = addWorktree('wt-a');
|
||||
await analyze(main);
|
||||
await analyze(wt);
|
||||
const shared = graphOf(wt);
|
||||
expect(path.dirname(path.dirname(shared))).toBe(layoutOf(wt).commitsDir);
|
||||
|
||||
const logs: string[] = [];
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(
|
||||
wt,
|
||||
{ force: true },
|
||||
{ onProgress: () => {}, onLog: (m) => logs.push(m) },
|
||||
);
|
||||
|
||||
expect(logs.some((m) => m.startsWith('Shared store: copied the shared graph'))).toBe(false);
|
||||
expect(await queryNames(graphOf(wt))).toEqual(['alpha', 'beta']);
|
||||
expect(existsSync(shared)).toBe(true);
|
||||
}, 240_000);
|
||||
|
||||
it('matches a from-scratch build after seeding and updating (R9)', async () => {
|
||||
const wt = addWorktree('wt-a');
|
||||
await analyze(main);
|
||||
await fs.writeFile(path.join(wt, 'b.ts'), 'export function betaTwo() { return 22; }\n');
|
||||
await fs.writeFile(path.join(wt, 'c.ts'), 'export function gamma() { return 3; }\n');
|
||||
await analyze(wt);
|
||||
const seeded = await queryNames(graphOf(wt));
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(wt, { force: true }, { onProgress: () => {} });
|
||||
expect(await queryNames(graphOf(wt))).toEqual(seeded);
|
||||
expect(seeded).toEqual(['alpha', 'betaTwo', 'gamma']);
|
||||
}, 240_000);
|
||||
});
|
||||
|
||||
describe('ensurePrivateSharedGraph', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-test-private-home-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
const pointerSlot = async (): Promise<{ slot: string; graph: string }> => {
|
||||
const storeRoot = path.join(tmpHome.dbPath, 'stores', 'repo-0123456789ab');
|
||||
const slot = path.join(storeRoot, 'checkouts', 'wt-0123456789ab');
|
||||
const graph = path.join(storeRoot, 'commits', 'abc1234-deadbeefdeadbeef', 'lbug');
|
||||
await fs.mkdir(path.dirname(graph), { recursive: true });
|
||||
await fs.mkdir(slot, { recursive: true });
|
||||
await saveMeta(slot, {
|
||||
repoPath: '/tmp/wt',
|
||||
storagePath: slot,
|
||||
lastCommit: 'abc1234',
|
||||
indexedAt: new Date().toISOString(),
|
||||
graphPath: graph,
|
||||
});
|
||||
return { slot, graph };
|
||||
};
|
||||
|
||||
it('copies the shared graph and clears the pointer', async () => {
|
||||
const { slot, graph } = await pointerSlot();
|
||||
await fs.writeFile(graph, 'shared graph bytes');
|
||||
expect(await ensurePrivateSharedGraph(slot, () => {})).toBe(true);
|
||||
expect(await fs.readFile(path.join(slot, 'lbug'), 'utf-8')).toBe('shared graph bytes');
|
||||
expect((await loadMeta(slot))?.graphPath).toBeUndefined();
|
||||
expect(await fs.readFile(graph, 'utf-8')).toBe('shared graph bytes');
|
||||
});
|
||||
|
||||
it('reports an unusable baseline when the shared graph is gone', async () => {
|
||||
const { slot } = await pointerSlot();
|
||||
const logs: string[] = [];
|
||||
expect(await ensurePrivateSharedGraph(slot, (m) => logs.push(m))).toBe(false);
|
||||
expect(existsSync(path.join(slot, 'lbug'))).toBe(false);
|
||||
expect(logs.join('\n')).toMatch(/shared graph unavailable \(ENOENT\)/);
|
||||
expect((await fs.readdir(slot)).filter((n) => n.startsWith('lbug'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('with copy: false drops the pointer without copying', async () => {
|
||||
const { slot, graph } = await pointerSlot();
|
||||
await fs.writeFile(graph, 'shared graph bytes');
|
||||
expect(await ensurePrivateSharedGraph(slot, () => {}, { copy: false })).toBe(true);
|
||||
expect(existsSync(path.join(slot, 'lbug'))).toBe(false);
|
||||
expect((await loadMeta(slot))?.graphPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is a no-op for a slot that already owns its graph', async () => {
|
||||
const { slot } = await pointerSlot();
|
||||
const meta = await loadMeta(slot);
|
||||
delete meta?.graphPath;
|
||||
await saveMeta(slot, meta as NonNullable<typeof meta>);
|
||||
await fs.writeFile(path.join(slot, 'lbug'), 'private graph bytes');
|
||||
expect(await ensurePrivateSharedGraph(slot, () => {})).toBe(true);
|
||||
expect(await fs.readFile(path.join(slot, 'lbug'), 'utf-8')).toBe('private graph bytes');
|
||||
});
|
||||
});
|
||||
|
|
@ -49,6 +49,7 @@ describe('#2112: analyze-worker IPC projection', () => {
|
|||
alreadyUpToDate: false,
|
||||
ftsRepairedOnly: undefined,
|
||||
ftsSkipped: true,
|
||||
storagePath: '/repos/demo/.gitnexus',
|
||||
});
|
||||
expect('pipelineResult' in projected).toBe(false);
|
||||
});
|
||||
|
|
|
|||
222
gitnexus/test/unit/api-delete-repo.test.ts
Normal file
222
gitnexus/test/unit/api-delete-repo.test.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* DELETE /api/repo on a shared-store checkout slot (#3374).
|
||||
*
|
||||
* The handler must remove the slot the way `gitnexus remove` does: the
|
||||
* checkout's `store.json` pointer and the registry entry go under the slot's
|
||||
* index lock. When an analyze holds that lock the handler answers 409 and
|
||||
* leaves the entry registered, instead of swallowing the failure and
|
||||
* reporting `{deleted}`.
|
||||
*
|
||||
* Boots createServer like analyze-delete-api.test.ts (mocked listen, no
|
||||
* LadybugDB/MCP); the registry and storage are real, under a temp
|
||||
* GITNEXUS_HOME.
|
||||
*/
|
||||
import express from 'express';
|
||||
import { existsSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { JobManager } from '../../src/server/analyze-job.js';
|
||||
import { acquireIndexLock } from '../../src/storage/index-lock.js';
|
||||
import { readRegistry, type RegistryEntry } from '../../src/storage/repo-manager.js';
|
||||
import { SHARED_STORE_POINTER } from '../../src/storage/shared-store.js';
|
||||
import { writeSharedStorePointer } from '../../src/storage/shared-store-lifecycle.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
const captured = vi.hoisted(() => ({
|
||||
managers: [] as JobManager[],
|
||||
}));
|
||||
|
||||
// Deletability policy is covered by storage-resolver tests; here the slot is
|
||||
// taken as deletable so the handler's own removal path is what runs.
|
||||
vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../src/storage/storage-resolver.js')>()),
|
||||
requireDeletableStoragePath: vi.fn(async (entry: { storagePath: string }) => entry.storagePath),
|
||||
}));
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
withLbugDb: vi.fn(),
|
||||
executeQuery: vi.fn(async () => []),
|
||||
executePrepared: vi.fn(async () => []),
|
||||
executeWithReusedStatement: vi.fn(async () => []),
|
||||
streamQuery: vi.fn(async () => 0),
|
||||
flushWAL: vi.fn(),
|
||||
closeLbug: vi.fn(),
|
||||
isReadOnlyDbError: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock('../../src/core/search/bm25-index.js', () => ({ searchFTSFromLbug: vi.fn() }));
|
||||
vi.mock('../../src/mcp/local/local-backend.js', () => ({
|
||||
LocalBackend: class {
|
||||
async init() {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/server/mcp-http.js', () => ({
|
||||
installServeMcpAuth: vi.fn(),
|
||||
mountMCPEndpoints: vi.fn(async () => vi.fn()),
|
||||
}));
|
||||
vi.mock('../../src/server/upload-sweep.js', () => ({ sweepStaleUploads: vi.fn(async () => {}) }));
|
||||
vi.mock('../../src/server/update-controller.js', () => ({
|
||||
createServeUpdateController: vi.fn(() => ({ stop: vi.fn() })),
|
||||
bindServeUpdateControllerLifecycle: vi.fn(),
|
||||
buildServerInfo: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../src/server/grep-scan.js', () => ({
|
||||
runGrepScanInWorker: vi.fn(async () => ({ results: [], timedOut: false })),
|
||||
}));
|
||||
vi.mock('../../src/server/sse-progress.js', () => ({ mountSSEProgress: vi.fn() }));
|
||||
vi.mock('../../src/server/analyze-job.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/server/analyze-job.js')>();
|
||||
return {
|
||||
...actual,
|
||||
JobManager: class extends actual.JobManager {
|
||||
constructor() {
|
||||
super();
|
||||
captured.managers.push(this);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { createServer } from '../../src/server/api.js';
|
||||
|
||||
interface HandlerResponse {
|
||||
statusCode: number;
|
||||
body: unknown;
|
||||
status(code: number): HandlerResponse;
|
||||
json(body: unknown): HandlerResponse;
|
||||
}
|
||||
|
||||
interface RouteLayer {
|
||||
route?: {
|
||||
path: string;
|
||||
methods?: Record<string, boolean>;
|
||||
stack: Array<{ handle: (req: unknown, res: HandlerResponse) => Promise<void> }>;
|
||||
};
|
||||
}
|
||||
|
||||
let app: express.Express;
|
||||
const events = ['SIGINT', 'SIGTERM', 'uncaughtException', 'unhandledRejection'] as const;
|
||||
const originalListeners = new Map(events.map((event) => [event, process.listeners(event)]));
|
||||
const saved = {
|
||||
home: process.env.GITNEXUS_HOME,
|
||||
lockTimeout: process.env.GITNEXUS_INDEX_LOCK_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
const restoreEnv = (name: string, value: string | undefined): void => {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const listen = vi.spyOn(express.application, 'listen').mockImplementation(function (
|
||||
this: express.Express,
|
||||
...args: unknown[]
|
||||
) {
|
||||
app = this;
|
||||
queueMicrotask(args.at(-1) as () => void);
|
||||
return new EventEmitter() as ReturnType<express.Express['listen']>;
|
||||
});
|
||||
try {
|
||||
await createServer(0);
|
||||
} finally {
|
||||
listen.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const manager of captured.managers) manager.dispose();
|
||||
for (const event of events) {
|
||||
for (const listener of process.listeners(event)) {
|
||||
if (!originalListeners.get(event)?.includes(listener)) {
|
||||
process.removeListener(event, listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const invokeDeleteRepo = async (repo: string): Promise<HandlerResponse> => {
|
||||
const layer = (app.router.stack as unknown as RouteLayer[]).find(
|
||||
(item) => item.route?.path === '/api/repo' && item.route.methods?.delete,
|
||||
);
|
||||
const handler = layer?.route?.stack.at(-1)?.handle;
|
||||
expect(handler, 'DELETE /api/repo').toBeDefined();
|
||||
const res: HandlerResponse = {
|
||||
statusCode: 200,
|
||||
body: undefined,
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(body) {
|
||||
this.body = body;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
await handler?.({ query: { repo }, body: {} }, res);
|
||||
return res;
|
||||
};
|
||||
|
||||
describe('DELETE /api/repo on a shared-store slot (#3374)', () => {
|
||||
let home: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let checkout: string;
|
||||
let slot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await createTempDir('gitnexus-test-delete-repo-');
|
||||
const root = await fs.realpath(home.dbPath);
|
||||
process.env.GITNEXUS_HOME = path.join(root, 'home');
|
||||
// A held lock must fail fast, not wait out the 10-minute default.
|
||||
process.env.GITNEXUS_INDEX_LOCK_TIMEOUT_MS = '200';
|
||||
checkout = path.join(root, 'checkout');
|
||||
const key = 'store-key';
|
||||
slot = path.join(process.env.GITNEXUS_HOME, 'stores', key, 'checkouts', 'slot-a');
|
||||
await fs.mkdir(checkout, { recursive: true });
|
||||
await fs.mkdir(slot, { recursive: true });
|
||||
await fs.writeFile(path.join(slot, 'gitnexus.json'), '{}\n');
|
||||
await writeSharedStorePointer(checkout, { key, checkoutSlot: slot });
|
||||
const entry: RegistryEntry = {
|
||||
name: 'checkout',
|
||||
path: checkout,
|
||||
storagePath: slot,
|
||||
indexedAt: '2026-01-01T00:00:00.000Z',
|
||||
lastCommit: 'abc123',
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(process.env.GITNEXUS_HOME, 'registry.json'),
|
||||
`${JSON.stringify([entry], null, 2)}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
restoreEnv('GITNEXUS_HOME', saved.home);
|
||||
restoreEnv('GITNEXUS_INDEX_LOCK_TIMEOUT_MS', saved.lockTimeout);
|
||||
await home.cleanup();
|
||||
});
|
||||
|
||||
it('removes the pointer, the slot and the registry entry', async () => {
|
||||
const res = await invokeDeleteRepo('checkout');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ deleted: 'checkout' });
|
||||
expect(existsSync(path.join(checkout, '.gitnexus', SHARED_STORE_POINTER))).toBe(false);
|
||||
expect(existsSync(slot)).toBe(false);
|
||||
expect(await readRegistry()).toEqual([]);
|
||||
});
|
||||
|
||||
it('answers 409 and keeps the entry while an analyze holds the slot lock', async () => {
|
||||
const lock = await acquireIndexLock(slot);
|
||||
try {
|
||||
const res = await invokeDeleteRepo('checkout');
|
||||
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.body).not.toHaveProperty('deleted');
|
||||
expect((await readRegistry()).map((e) => e.path)).toEqual([checkout]);
|
||||
expect(existsSync(path.join(checkout, '.gitnexus', SHARED_STORE_POINTER))).toBe(true);
|
||||
expect(existsSync(slot)).toBe(true);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1135,3 +1135,128 @@ describe('listWorkingTreeDirtyPaths', () => {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ─── isWorkingTreePristine ────────────────────────────────────────────────
|
||||
//
|
||||
// The shared-store publish gate (#3374): a graph built from a working tree
|
||||
// that hides committed content (sparse checkout, index bits, an uninitialized
|
||||
// submodule) must never become the commit graph other checkouts reuse.
|
||||
|
||||
/** A repo with two committed files, `a.ts` and `lib/b.ts`. */
|
||||
function makeCommittedRepo(): string {
|
||||
const repo = makeIsolatedGitRepo();
|
||||
fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;');
|
||||
fs.mkdirSync(path.join(repo, 'lib'));
|
||||
fs.writeFileSync(path.join(repo, 'lib', 'b.ts'), 'export const b = 1;');
|
||||
execFileSync(gitExecutable, ['add', '-A'], { cwd: repo, stdio: 'ignore' });
|
||||
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], { cwd: repo, stdio: 'ignore' });
|
||||
return repo;
|
||||
}
|
||||
|
||||
const gitIn = (repo: string, ...args: string[]): string =>
|
||||
execFileSync(gitExecutable, args, { cwd: repo, encoding: 'utf8' }).trim();
|
||||
|
||||
describe('isWorkingTreePristine', () => {
|
||||
it('is true for a clean checkout, ignoring GitNexus-managed writes', async () => {
|
||||
const { isWorkingTreePristine } = await import('../../src/storage/git.js');
|
||||
const repo = makeCommittedRepo();
|
||||
try {
|
||||
fs.mkdirSync(path.join(repo, '.gitnexus'));
|
||||
fs.writeFileSync(path.join(repo, '.gitnexus', 'meta.json'), '{}');
|
||||
fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'x');
|
||||
|
||||
expect(isWorkingTreePristine(repo)).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('is false with an untracked source file', async () => {
|
||||
const { isWorkingTreePristine } = await import('../../src/storage/git.js');
|
||||
const repo = makeCommittedRepo();
|
||||
try {
|
||||
fs.writeFileSync(path.join(repo, 'new.ts'), 'export const n = 1;');
|
||||
|
||||
expect(isWorkingTreePristine(repo)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['--assume-unchanged', '--skip-worktree'])(
|
||||
'is false when git update-index %s hides a path, even with unchanged content',
|
||||
async (flag) => {
|
||||
const { isWorkingTreePristine } = await import('../../src/storage/git.js');
|
||||
const repo = makeCommittedRepo();
|
||||
try {
|
||||
gitIn(repo, 'update-index', flag, '--', 'a.ts');
|
||||
|
||||
expect(isWorkingTreePristine(repo)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Every sparse mode marks the left-out entries skip-worktree, which is what
|
||||
// the check reads; a sparse index still expands for `git ls-files -v`.
|
||||
it.each([
|
||||
['no-cone', ['set', '--no-cone', '/a.ts']],
|
||||
['cone', ['set', '--cone']],
|
||||
['cone with a sparse index', ['set', '--cone', '--sparse-index']],
|
||||
])('is false in a %s sparse checkout that leaves committed files out', async (_mode, args) => {
|
||||
const { isWorkingTreePristine } = await import('../../src/storage/git.js');
|
||||
const repo = makeCommittedRepo();
|
||||
try {
|
||||
gitIn(repo, 'sparse-checkout', ...args);
|
||||
|
||||
expect(fs.existsSync(path.join(repo, 'lib', 'b.ts'))).toBe(false);
|
||||
expect(isWorkingTreePristine(repo)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('is false with a registered but uninitialized submodule', async () => {
|
||||
const { isWorkingTreePristine } = await import('../../src/storage/git.js');
|
||||
const repo = makeCommittedRepo();
|
||||
try {
|
||||
const head = gitIn(repo, 'rev-parse', 'HEAD');
|
||||
gitIn(repo, 'update-index', '--add', '--cacheinfo', `160000,${head},vendor/dep`);
|
||||
gitIn(repo, 'commit', '-q', '-m', 'add gitlink');
|
||||
|
||||
expect(isWorkingTreePristine(repo)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('is true with a checked-out submodule', async () => {
|
||||
const { isWorkingTreePristine } = await import('../../src/storage/git.js');
|
||||
const repo = makeCommittedRepo();
|
||||
try {
|
||||
const sub = path.join(repo, 'vendor', 'dep');
|
||||
fs.mkdirSync(sub, { recursive: true });
|
||||
gitIn(sub, 'init', '-q');
|
||||
fs.writeFileSync(path.join(sub, 'c.ts'), 'export const c = 1;');
|
||||
gitIn(sub, 'add', '-A');
|
||||
gitIn(sub, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'sub');
|
||||
gitIn(repo, 'add', 'vendor/dep');
|
||||
gitIn(repo, 'commit', '-q', '-m', 'add submodule');
|
||||
|
||||
expect(isWorkingTreePristine(repo)).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('is false (fails closed) outside a git repository', async () => {
|
||||
const { isWorkingTreePristine } = await import('../../src/storage/git.js');
|
||||
const dir = makeIsolatedTempDir('gn-nongit-pristine-');
|
||||
try {
|
||||
expect(isWorkingTreePristine(dir)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
194
gitnexus/test/unit/hooks-shared-store.test.ts
Normal file
194
gitnexus/test/unit/hooks-shared-store.test.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { execFileSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { slotNameForCanonicalPath } from '../../src/storage/storage-slot.js';
|
||||
|
||||
/**
|
||||
* #3352 — the Claude hook resolves a shared-store checkout to the commit
|
||||
* graph it reads, mirroring `resolveGraphPath` in src/storage/shared-store.ts.
|
||||
*/
|
||||
const HOOK_COPIES = [
|
||||
path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'registry-query.cjs'),
|
||||
path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'gitnexus-claude-plugin',
|
||||
'hooks',
|
||||
'registry-query.cjs',
|
||||
),
|
||||
path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'gitnexus-cursor-integration',
|
||||
'hooks',
|
||||
'registry-query.cjs',
|
||||
),
|
||||
path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'gitnexus-factory-plugin',
|
||||
'hooks',
|
||||
'registry-query.cjs',
|
||||
),
|
||||
];
|
||||
|
||||
type HookRepo = { storagePath: string; lbugPath: string } | null;
|
||||
const load = (file: string) =>
|
||||
createRequire(import.meta.url)(file) as { findRegisteredRepo: (cwd: string) => HookRepo };
|
||||
|
||||
describe('registry-query shared store graph (#3352)', () => {
|
||||
let tmp: string;
|
||||
let home: string;
|
||||
let checkout: string;
|
||||
let slot: string;
|
||||
let commitGraph: string;
|
||||
const savedHome = process.env.GITNEXUS_HOME;
|
||||
// Either storage override takes precedence over the registry row in the hook.
|
||||
const savedStoragePath = process.env.GITNEXUS_STORAGE_PATH;
|
||||
const savedStorageRoot = process.env.GITNEXUS_STORAGE_ROOT;
|
||||
|
||||
const writeSlot = (meta: Record<string, unknown>) => {
|
||||
fs.mkdirSync(slot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(slot, 'gitnexus.json'),
|
||||
JSON.stringify({ repoPath: checkout, storagePath: slot, lastCommit: 'abc', ...meta }),
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gn-hook-shared-')));
|
||||
home = path.join(tmp, 'home');
|
||||
checkout = path.join(tmp, 'wt');
|
||||
fs.mkdirSync(checkout, { recursive: true });
|
||||
execFileSync('git', ['init', '-q'], { cwd: checkout, stdio: 'ignore' });
|
||||
const store = path.join(home, 'stores', 'repo-0123456789ab');
|
||||
slot = path.join(store, 'checkouts', 'wt-0123456789ab');
|
||||
commitGraph = path.join(store, 'commits', 'abc1234-deadbeefdeadbeef', 'lbug');
|
||||
fs.mkdirSync(path.dirname(commitGraph), { recursive: true });
|
||||
fs.writeFileSync(commitGraph, 'graph');
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(home, 'registry.json'),
|
||||
JSON.stringify([
|
||||
{ name: 'wt', path: checkout, storagePath: slot, indexedAt: '', lastCommit: '' },
|
||||
]),
|
||||
);
|
||||
process.env.GITNEXUS_HOME = home;
|
||||
delete process.env.GITNEXUS_STORAGE_PATH;
|
||||
delete process.env.GITNEXUS_STORAGE_ROOT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
if (savedStoragePath === undefined) delete process.env.GITNEXUS_STORAGE_PATH;
|
||||
else process.env.GITNEXUS_STORAGE_PATH = savedStoragePath;
|
||||
if (savedStorageRoot === undefined) delete process.env.GITNEXUS_STORAGE_ROOT;
|
||||
else process.env.GITNEXUS_STORAGE_ROOT = savedStorageRoot;
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('keeps the four hook copies byte-identical', () => {
|
||||
const [primary, ...copies] = HOOK_COPIES.map((f) => fs.readFileSync(f, 'utf-8'));
|
||||
for (const copy of copies) expect(copy).toBe(primary);
|
||||
});
|
||||
|
||||
it.each(HOOK_COPIES)('returns the commit graph a shared slot records (%s)', (file) => {
|
||||
writeSlot({ graphPath: commitGraph });
|
||||
expect(load(file).findRegisteredRepo(checkout)?.lbugPath).toBe(commitGraph);
|
||||
});
|
||||
|
||||
it('returns the slot graph when none is recorded', () => {
|
||||
writeSlot({});
|
||||
expect(load(HOOK_COPIES[0]).findRegisteredRepo(checkout)?.lbugPath).toBe(
|
||||
path.join(slot, 'lbug'),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['outside the store', () => '/etc/lbug'],
|
||||
['a sibling slot', () => path.join(path.dirname(slot), 'other-000000000000', 'lbug')],
|
||||
['a relative path', () => 'commits/abc1234-deadbeefdeadbeef/lbug'],
|
||||
])('ignores a recorded graphPath %s', (_label, graphPath) => {
|
||||
writeSlot({ graphPath: graphPath() });
|
||||
expect(load(HOOK_COPIES[0]).findRegisteredRepo(checkout)?.lbugPath).toBe(
|
||||
path.join(slot, 'lbug'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #3374 — the hook's slot name must match `slotNameForCanonicalPath` for
|
||||
* device-name basenames on both platform branches, or a GITNEXUS_STORAGE_ROOT
|
||||
* index is invisible to the hook. The checkout itself is never created (the
|
||||
* hook falls back to the resolved path); only the slot is written. A real
|
||||
* Windows host cannot create the POSIX-branch slot (`CON.txt-<hash>`), so the
|
||||
* stubbed rows run on POSIX hosts, which exercise both branches.
|
||||
*/
|
||||
describe.skipIf(process.platform === 'win32')('registry-query slot name parity (#3374)', () => {
|
||||
const realPlatform = process.platform;
|
||||
const savedHome = process.env.GITNEXUS_HOME;
|
||||
const savedStoragePath = process.env.GITNEXUS_STORAGE_PATH;
|
||||
const savedStorageRoot = process.env.GITNEXUS_STORAGE_ROOT;
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gn-hook-slot-')));
|
||||
process.env.GITNEXUS_HOME = path.join(tmp, 'home');
|
||||
process.env.GITNEXUS_STORAGE_ROOT = path.join(tmp, 'root');
|
||||
delete process.env.GITNEXUS_STORAGE_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true });
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
if (savedStoragePath === undefined) delete process.env.GITNEXUS_STORAGE_PATH;
|
||||
else process.env.GITNEXUS_STORAGE_PATH = savedStoragePath;
|
||||
if (savedStorageRoot === undefined) delete process.env.GITNEXUS_STORAGE_ROOT;
|
||||
else process.env.GITNEXUS_STORAGE_ROOT = savedStorageRoot;
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const basenames = [
|
||||
'CON',
|
||||
'con.txt',
|
||||
'NUL.tar.gz',
|
||||
'COM1',
|
||||
'LPT9.log',
|
||||
'aux',
|
||||
'prn.',
|
||||
'normal',
|
||||
'CONSOLE',
|
||||
'com0.txt',
|
||||
];
|
||||
const rows = (['win32', 'linux'] as const).flatMap((platform) =>
|
||||
basenames.map((basename) => [platform, basename] as const),
|
||||
);
|
||||
|
||||
it.each(rows)('on %s resolves the slot storage-slot.ts names for %s', (platform, basename) => {
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
|
||||
const checkout = path.join(tmp, 'repos', basename);
|
||||
const slot = path.join(tmp, 'root', slotNameForCanonicalPath(checkout));
|
||||
fs.mkdirSync(slot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(slot, 'gitnexus.json'),
|
||||
JSON.stringify({ repoPath: checkout, storagePath: slot, lastCommit: 'abc' }),
|
||||
);
|
||||
fs.mkdirSync(path.join(tmp, 'home'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, 'home', 'registry.json'),
|
||||
JSON.stringify([{ name: basename, path: checkout, indexedAt: '', lastCommit: '' }]),
|
||||
);
|
||||
expect(load(HOOK_COPIES[0]).findRegisteredRepo(checkout)?.storagePath).toBe(slot);
|
||||
});
|
||||
});
|
||||
398
gitnexus/test/unit/storage/shared-store.test.ts
Normal file
398
gitnexus/test/unit/storage/shared-store.test.ts
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
import { execFileSync } from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
commitGraphDir,
|
||||
isSharedStoreDisabled,
|
||||
readSharedStorePointer,
|
||||
resolveGraphPath,
|
||||
resolveSharedStore,
|
||||
resolveSharedStoreKey,
|
||||
SHARED_STORE_ENV,
|
||||
sharedStoreLayout,
|
||||
type SharedStoreLayout,
|
||||
} from '../../../src/storage/shared-store.js';
|
||||
import { sanitizeSlotBasename } from '../../../src/storage/storage-slot.js';
|
||||
import {
|
||||
STORAGE_PATH_ENV,
|
||||
STORAGE_ROOT_ENV,
|
||||
resolveStoragePath,
|
||||
storageSlotName,
|
||||
} from '../../../src/storage/storage-resolver.js';
|
||||
import { getStoragePaths } from '../../../src/storage/repo-manager.js';
|
||||
|
||||
const temporaryPaths: string[] = [];
|
||||
const savedHome = process.env.GITNEXUS_HOME;
|
||||
let home: string;
|
||||
|
||||
const makeTempDir = async (prefix: string): Promise<string> => {
|
||||
const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), prefix)));
|
||||
temporaryPaths.push(dir);
|
||||
return dir;
|
||||
};
|
||||
|
||||
const git = (cwd: string, ...args: string[]): void => {
|
||||
execFileSync('git', args, { cwd, stdio: 'ignore' });
|
||||
};
|
||||
|
||||
/** A committed repo; `worktrees` names linked worktrees created beside it. */
|
||||
const makeRepo = async (worktrees: string[] = []): Promise<{ main: string; wts: string[] }> => {
|
||||
const parent = await makeTempDir('gn-shared-store-');
|
||||
const main = path.join(parent, 'main');
|
||||
await fs.mkdir(main);
|
||||
git(main, 'init', '-q', '-b', 'main');
|
||||
git(
|
||||
main,
|
||||
'-c',
|
||||
'user.email=t@t',
|
||||
'-c',
|
||||
'user.name=t',
|
||||
'commit',
|
||||
'-q',
|
||||
'--allow-empty',
|
||||
'-m',
|
||||
'init',
|
||||
);
|
||||
const wts = worktrees.map((name) => {
|
||||
const wt = path.join(parent, name);
|
||||
git(main, 'worktree', 'add', '-q', '-b', name, wt);
|
||||
return wt;
|
||||
});
|
||||
return { main, wts };
|
||||
};
|
||||
|
||||
// Only these keys are read by isSharedStoreDisabled/resolveSharedStoreKey.
|
||||
const cleanEnv = (): NodeJS.ProcessEnv => ({});
|
||||
|
||||
const layoutOf = (checkoutPath: string): SharedStoreLayout => {
|
||||
const layout = resolveSharedStore(checkoutPath, cleanEnv());
|
||||
expect(layout).not.toBeNull();
|
||||
return layout as SharedStoreLayout;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await makeTempDir('gn-shared-home-');
|
||||
process.env.GITNEXUS_HOME = home;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await Promise.all(
|
||||
temporaryPaths.splice(0).map((p) => fs.rm(p, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe('resolveSharedStoreKey', () => {
|
||||
it('gives the main checkout and every linked worktree the same key', async () => {
|
||||
const { main, wts } = await makeRepo(['wt-a', 'wt-b']);
|
||||
const keys = [main, ...wts].map((p) => resolveSharedStoreKey(p, cleanEnv()));
|
||||
expect(keys[0]).toMatch(/^main-[0-9a-f]{12}$/);
|
||||
expect(new Set(keys).size).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a repository without linked worktrees on local storage', async () => {
|
||||
const { main } = await makeRepo();
|
||||
expect(resolveSharedStoreKey(main, cleanEnv())).toBeNull();
|
||||
});
|
||||
|
||||
it('gives two unrelated repos with the same basename different keys', async () => {
|
||||
const a = await makeRepo(['wt']);
|
||||
const b = await makeRepo(['wt']);
|
||||
const keyA = resolveSharedStoreKey(a.main, cleanEnv());
|
||||
const keyB = resolveSharedStoreKey(b.main, cleanEnv());
|
||||
expect(keyA).not.toBeNull();
|
||||
expect(keyA).not.toBe(keyB);
|
||||
});
|
||||
|
||||
it('does not share a subdirectory of a checkout', async () => {
|
||||
const { main } = await makeRepo(['wt']);
|
||||
const sub = path.join(main, 'pkg');
|
||||
await fs.mkdir(sub);
|
||||
expect(resolveSharedStoreKey(sub, cleanEnv())).toBeNull();
|
||||
});
|
||||
|
||||
it('does not share a non-git folder', async () => {
|
||||
const dir = await makeTempDir('gn-shared-nogit-');
|
||||
expect(resolveSharedStoreKey(dir, cleanEnv())).toBeNull();
|
||||
});
|
||||
|
||||
it('does not treat a gitdir file without commondir (submodule shape) as a worktree', async () => {
|
||||
const dir = await makeTempDir('gn-shared-submodule-');
|
||||
const modules = path.join(dir, 'modules', 'sub');
|
||||
await fs.mkdir(modules, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, '.git'), `gitdir: ${modules}\n`);
|
||||
expect(resolveSharedStoreKey(dir, cleanEnv())).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ [SHARED_STORE_ENV]: 'off' }],
|
||||
[{ [SHARED_STORE_ENV]: 'FALSE' }],
|
||||
[{ [SHARED_STORE_ENV]: '0' }],
|
||||
[{ [STORAGE_PATH_ENV]: '/tmp/explicit-index' }],
|
||||
[{ [STORAGE_ROOT_ENV]: '/tmp/index-root' }],
|
||||
])('returns null for every checkout when disabled by %o', async (env) => {
|
||||
const { main, wts } = await makeRepo(['wt']);
|
||||
expect(isSharedStoreDisabled(env)).toBe(true);
|
||||
expect(resolveSharedStoreKey(main, env)).toBeNull();
|
||||
expect(resolveSharedStoreKey(wts[0], env)).toBeNull();
|
||||
});
|
||||
|
||||
it('treats an unrecognized switch value as enabled', () => {
|
||||
expect(isSharedStoreDisabled({ [SHARED_STORE_ENV]: 'on' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharedStoreLayout', () => {
|
||||
it('places every area inside the store under GITNEXUS_HOME', async () => {
|
||||
const { main, wts } = await makeRepo(['wt']);
|
||||
const layout = layoutOf(wts[0]);
|
||||
const root = path.join(home, 'stores', layout.key);
|
||||
expect(layout).toEqual({
|
||||
key: resolveSharedStoreKey(main, cleanEnv()),
|
||||
root,
|
||||
cachesDir: path.join(root, 'caches'),
|
||||
commitsDir: path.join(root, 'commits'),
|
||||
checkoutsDir: path.join(root, 'checkouts'),
|
||||
checkoutSlot: path.join(root, 'checkouts', storageSlotName(wts[0])),
|
||||
canonicalCheckout: main,
|
||||
});
|
||||
});
|
||||
|
||||
it('gives each checkout its own slot', async () => {
|
||||
const { main, wts } = await makeRepo(['wt']);
|
||||
const a = layoutOf(main);
|
||||
const b = layoutOf(wts[0]);
|
||||
expect(a.root).toBe(b.root);
|
||||
expect(a.checkoutSlot).not.toBe(b.checkoutSlot);
|
||||
});
|
||||
|
||||
it('maps a symlinked spelling of a worktree to the same slot', async () => {
|
||||
const { wts } = await makeRepo(['wt']);
|
||||
const link = path.join(await makeTempDir('gn-shared-link-'), 'alias');
|
||||
await fs.symlink(wts[0], link, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
expect(layoutOf(link).checkoutSlot).toBe(layoutOf(wts[0]).checkoutSlot);
|
||||
});
|
||||
|
||||
it.each(['..', '../escape', 'a/../../b'])(
|
||||
'rejects a key that escapes the stores dir: %s',
|
||||
(key) => {
|
||||
expect(() => sharedStoreLayout(key, '/tmp/x')).toThrow(/escapes the stores directory/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('commitGraphDir', () => {
|
||||
const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout');
|
||||
|
||||
it('names one directory per commit and feature key', () => {
|
||||
expect(commitGraphDir(layout, 'abc1234', 'deadbeef')).toBe(
|
||||
path.join(layout.commitsDir, 'abc1234-deadbeef'),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['../../x', 'deadbeef'],
|
||||
['ABC1234', 'deadbeef'],
|
||||
['abc1234', '../etc'],
|
||||
['abc1234', 'short'],
|
||||
])('rejects commit %s / feature key %s', (commit, featureKey) => {
|
||||
expect(() => commitGraphDir(layout, commit, featureKey)).toThrow(/Invalid/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGraphPath', () => {
|
||||
const writeSlotMeta = async (slot: string, meta: Record<string, unknown>): Promise<void> => {
|
||||
await fs.mkdir(slot, { recursive: true });
|
||||
await fs.writeFile(path.join(slot, 'gitnexus.json'), JSON.stringify(meta));
|
||||
};
|
||||
|
||||
it('returns <storagePath>/lbug for non-shared storage without reading metadata', async () => {
|
||||
const dir = await makeTempDir('gn-shared-local-');
|
||||
const storagePath = path.join(dir, '.gitnexus');
|
||||
await writeSlotMeta(storagePath, { graphPath: path.join(dir, 'elsewhere', 'lbug') });
|
||||
expect(resolveGraphPath(storagePath)).toBe(path.join(storagePath, 'lbug'));
|
||||
});
|
||||
|
||||
it('returns the recorded commit graph for a shared checkout slot', async () => {
|
||||
const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout-a');
|
||||
const graph = path.join(commitGraphDir(layout, 'abc1234', 'deadbeef'), 'lbug');
|
||||
await writeSlotMeta(layout.checkoutSlot, { graphPath: graph });
|
||||
expect(resolveGraphPath(layout.checkoutSlot)).toBe(graph);
|
||||
});
|
||||
|
||||
it('returns the slot graph when no graphPath is recorded', async () => {
|
||||
const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout-a');
|
||||
await writeSlotMeta(layout.checkoutSlot, { repoPath: '/tmp/checkout-a' });
|
||||
expect(resolveGraphPath(layout.checkoutSlot)).toBe(path.join(layout.checkoutSlot, 'lbug'));
|
||||
});
|
||||
|
||||
it('returns the slot graph when metadata is missing or unparseable', async () => {
|
||||
const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout-a');
|
||||
const own = path.join(layout.checkoutSlot, 'lbug');
|
||||
expect(resolveGraphPath(layout.checkoutSlot)).toBe(own);
|
||||
await fs.mkdir(layout.checkoutSlot, { recursive: true });
|
||||
await fs.writeFile(path.join(layout.checkoutSlot, 'gitnexus.json'), '{not json');
|
||||
expect(resolveGraphPath(layout.checkoutSlot)).toBe(own);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'another store',
|
||||
() => path.join(home, 'stores', 'other-000000000000', 'commits', 'abc1234-deadbeef', 'lbug'),
|
||||
],
|
||||
[
|
||||
'a sibling private slot',
|
||||
(l: SharedStoreLayout) => path.join(l.checkoutsDir, 'sibling-000000000000', 'lbug'),
|
||||
],
|
||||
['outside GITNEXUS_HOME', () => '/etc/lbug'],
|
||||
['a relative path', () => 'commits/abc1234-deadbeef/lbug'],
|
||||
['a traversal', (l: SharedStoreLayout) => path.join(l.commitsDir, '..', '..', 'x', 'lbug')],
|
||||
[
|
||||
'in-progress publish staging',
|
||||
(l: SharedStoreLayout) => path.join(l.commitsDir, '.publish-0f3c', 'lbug'),
|
||||
],
|
||||
[
|
||||
'a non-lbug file',
|
||||
(l: SharedStoreLayout) => path.join(l.commitsDir, 'abc1234-deadbeef', 'gitnexus.json'),
|
||||
],
|
||||
])('ignores a recorded graphPath in %s', async (_label, graphPathFor) => {
|
||||
const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout-a');
|
||||
await writeSlotMeta(layout.checkoutSlot, { graphPath: graphPathFor(layout) });
|
||||
expect(resolveGraphPath(layout.checkoutSlot)).toBe(path.join(layout.checkoutSlot, 'lbug'));
|
||||
});
|
||||
|
||||
it('flows through getStoragePaths for the flat slot but not branch slots', async () => {
|
||||
const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout-a');
|
||||
const graph = path.join(commitGraphDir(layout, 'abc1234', 'deadbeef'), 'lbug');
|
||||
await writeSlotMeta(layout.checkoutSlot, { graphPath: graph });
|
||||
expect(getStoragePaths('/tmp/checkout-a', undefined, layout.checkoutSlot).lbugPath).toBe(graph);
|
||||
expect(
|
||||
path.dirname(getStoragePaths('/tmp/checkout-a', 'feature', layout.checkoutSlot).lbugPath),
|
||||
).toMatch(/branches/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStoragePath store tier', () => {
|
||||
const savedPath = process.env[STORAGE_PATH_ENV];
|
||||
const savedRoot = process.env[STORAGE_ROOT_ENV];
|
||||
const savedSwitch = process.env[SHARED_STORE_ENV];
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env[STORAGE_PATH_ENV];
|
||||
delete process.env[STORAGE_ROOT_ENV];
|
||||
delete process.env[SHARED_STORE_ENV];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of [
|
||||
[STORAGE_PATH_ENV, savedPath],
|
||||
[STORAGE_ROOT_ENV, savedRoot],
|
||||
[SHARED_STORE_ENV, savedSwitch],
|
||||
] as const) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps an unregistered worktree on local storage while its slot does not exist', async () => {
|
||||
const { wts } = await makeRepo(['wt']);
|
||||
expect(resolveStoragePath(wts[0])).toBe(path.join(wts[0], '.gitnexus'));
|
||||
});
|
||||
|
||||
it('resolves an unregistered worktree to its existing store slot', async () => {
|
||||
const { wts } = await makeRepo(['wt']);
|
||||
const slot = layoutOf(wts[0]).checkoutSlot;
|
||||
await fs.mkdir(slot, { recursive: true });
|
||||
expect(resolveStoragePath(wts[0])).toBe(slot);
|
||||
});
|
||||
|
||||
it('prefers a registered storage path over an existing store slot', async () => {
|
||||
const { wts } = await makeRepo(['wt']);
|
||||
await fs.mkdir(layoutOf(wts[0]).checkoutSlot, { recursive: true });
|
||||
const registered = path.join(await makeTempDir('gn-shared-registered-'), 'index');
|
||||
await fs.writeFile(
|
||||
path.join(home, 'registry.json'),
|
||||
JSON.stringify([{ name: 'wt', path: wts[0], storagePath: registered }]),
|
||||
);
|
||||
expect(resolveStoragePath(wts[0])).toBe(registered);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slot naming edge cases (#3352 review)', () => {
|
||||
it.each(['CON.txt', 'com1.log', 'Lpt9.tar.gz'])(
|
||||
'prefixes a reserved device name with an extension on Windows only: %s',
|
||||
(base) => {
|
||||
expect(sanitizeSlotBasename(base, 'win32')).toBe(`repository-${base}`);
|
||||
expect(sanitizeSlotBasename(base, 'linux')).toBe(base);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['CON', 'nul', 'COM1'])('prefixes an exact device name on every platform: %s', (base) => {
|
||||
expect(sanitizeSlotBasename(base, 'win32')).toBe(`repository-${base}`);
|
||||
expect(sanitizeSlotBasename(base, 'linux')).toBe(`repository-${base}`);
|
||||
});
|
||||
|
||||
it('keeps an ordinary name that only starts like a device name', () => {
|
||||
expect(storageSlotName(path.join(path.sep, 'tmp', 'console'))).toMatch(/^console-/);
|
||||
});
|
||||
|
||||
it('accepts a checkout slot whose name starts with two dots', async () => {
|
||||
const layout = sharedStoreLayout('..repo-0123456789ab', '/tmp/..checkout');
|
||||
const graph = path.join(commitGraphDir(layout, 'abc1234', 'deadbeef'), 'lbug');
|
||||
await fs.mkdir(layout.checkoutSlot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(layout.checkoutSlot, 'gitnexus.json'),
|
||||
JSON.stringify({ graphPath: graph }),
|
||||
);
|
||||
expect(resolveGraphPath(layout.checkoutSlot)).toBe(graph);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bare repositories (#3352 review)', () => {
|
||||
it('shares worktrees of a bare repository, keyed by the bare dir, with no main checkout', async () => {
|
||||
const parent = await makeTempDir('gn-shared-bare-');
|
||||
const src = path.join(parent, 'src');
|
||||
await fs.mkdir(src);
|
||||
git(src, 'init', '-q', '-b', 'main');
|
||||
git(
|
||||
src,
|
||||
'-c',
|
||||
'user.email=t@t',
|
||||
'-c',
|
||||
'user.name=t',
|
||||
'commit',
|
||||
'-q',
|
||||
'--allow-empty',
|
||||
'-m',
|
||||
'init',
|
||||
);
|
||||
const bare = path.join(parent, 'repo.git');
|
||||
git(parent, 'clone', '-q', '--bare', src, bare);
|
||||
const wtA = path.join(parent, 'wt-a');
|
||||
const wtB = path.join(parent, 'wt-b');
|
||||
git(bare, 'worktree', 'add', '-q', '-b', 'a', wtA);
|
||||
git(bare, 'worktree', 'add', '-q', '-b', 'b', wtB);
|
||||
|
||||
const a = layoutOf(wtA);
|
||||
expect(layoutOf(wtB).root).toBe(a.root);
|
||||
expect(a.key).toMatch(/^repo\.git-[0-9a-f]{12}$/);
|
||||
expect(a.canonicalCheckout).toBeNull();
|
||||
// The bare dir itself has no working tree to analyze.
|
||||
expect(resolveSharedStoreKey(bare, cleanEnv())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readSharedStorePointer malformed content (#3352 review)', () => {
|
||||
it.each(['null', '42', '"text"', '[]'])(
|
||||
'returns null for a pointer whose JSON is %s',
|
||||
async (body) => {
|
||||
const dir = await makeTempDir('gn-shared-ptr-');
|
||||
await fs.mkdir(path.join(dir, '.gitnexus'));
|
||||
await fs.writeFile(path.join(dir, '.gitnexus', 'store.json'), body);
|
||||
expect(readSharedStorePointer(dir)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -98,6 +98,14 @@ export default defineConfig({
|
|||
'test/integration/lbug-orphan-sidecar-recovery.test.ts',
|
||||
'test/integration/lbug-interrupted-checkpoint-recovery.test.ts',
|
||||
'test/integration/lbug-readonly-init.test.ts',
|
||||
// Shared sibling store (#3352): each file runs real analyses and opens
|
||||
// the resulting LadybugDB graphs.
|
||||
'test/integration/shared-store-analyze.test.ts',
|
||||
'test/integration/shared-store-seed.test.ts',
|
||||
'test/integration/shared-store-cache.test.ts',
|
||||
'test/integration/shared-store-clean.test.ts',
|
||||
'test/integration/shared-store-clone-optin.test.ts',
|
||||
'test/integration/shared-store-adoption.test.ts',
|
||||
'test/integration/analyze-wal-checkpoint-failure.test.ts',
|
||||
'test/integration/lbug-non-ascii-path.test.ts',
|
||||
'test/integration/lbug-conn-serialization.test.ts',
|
||||
|
|
@ -202,6 +210,12 @@ export default defineConfig({
|
|||
// Excluded here because it is included by `lbug-db` above; a file
|
||||
// in two projects would be collected (and run) twice.
|
||||
'test/unit/incremental-index-extension-dml-gate.test.ts',
|
||||
'test/integration/shared-store-analyze.test.ts',
|
||||
'test/integration/shared-store-seed.test.ts',
|
||||
'test/integration/shared-store-cache.test.ts',
|
||||
'test/integration/shared-store-clean.test.ts',
|
||||
'test/integration/shared-store-clone-optin.test.ts',
|
||||
'test/integration/shared-store-adoption.test.ts',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue