GitNexus/gitnexus/test/unit/git-utils.test.ts
Gergő Magyar ad5c7364e1
feat(storage): share one index store across linked worktrees and sibling clones (#3374)
* feat(storage): resolve the shared sibling-store identity and layout (#3352)

Linked worktrees of one repository resolve to one store under
GITNEXUS_HOME/stores/<key>, keyed by the canonical git common dir. The
resolver reads the .git entry directly, so hot paths spawn no git. Slot
naming moves to a leaf module so storage-resolver and shared-store do not
import each other.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(storage): resolve a shared checkout's graph and existing store slot (#3352)

getStoragePaths reads a flat slot's recorded graphPath only for checkout
slots under the stores directory; other paths keep <storagePath>/lbug with
no I/O. A recorded path outside the store's commit graphs is ignored.
resolveStoragePath falls back to an existing store slot for an
unregistered checkout, so reads never move to an empty slot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(analyze): share one immutable commit graph across clean worktrees (#3352)

A linked worktree writes its own slot in the shared store. A new slot
is seeded with a pointer to the nearest commit graph, so a clean
checkout at an indexed commit takes the up-to-date path and writes no
graph. A checkout with local changes gets a copy-on-write private
graph before its first write. After a successful run, a clean checkout
at HEAD publishes its graph into commits/ under a store lock, or drops
it when that commit graph already exists. Commit graphs are never
written after publish.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(analyze): seed a worktree's graph from the nearest index (#3352)

A new shared slot is seeded from the store's commit graph nearest to
HEAD, else from this checkout's or the main checkout's
repository-local index (copied under that index's lock, source left in
place). The run that follows is up to date or incremental instead of a
full build. If a pointed-at shared graph has been removed, analyze
falls back to a full build instead of an incremental update over a
missing baseline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(analyze): keep one parse cache per shared store (#3352)

Linked worktrees read and write the parse cache and durable ParsedFile
store under the store's caches/ directory. Before pruning, a run folds
in the chunk keys recorded by every member slot and commit graph, and
the fold, prune and save run under a store-wide cache lock so one
member never evicts another's live chunks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(clean): remove only what no shared-store member references (#3352)

Deleting a shared checkout slot (clean, clean --all, remove, and the
server delete route) recounts references under the store's publish
lock and deletes commit graphs no member points at, then the store
itself once empty. A graph that cannot be deleted (open on Windows) is
reported and kept for the next pass. clean --gc also drops member slots
whose worktree is gone or no longer resolves to the store.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(analyze): let a clone opt in to a shared store with --share-with (#3352)

An independent clone joins a linked worktree's shared store only with
analyze --share-with <repo>, and only when its normalized origin URL
matches that member's (credentials stripped, as #2054 compares). The
registry remembers the choice. --no-share moves an opted-in clone back
to its own .gitnexus and reclaims its old slot; linked worktrees always
share and are pointed at GITNEXUS_SHARED_STORE=off instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): count a shared checkout's commit graph as its code index (#3352)

A clean shared checkout reads a commit graph and owns no graph file, so
the code-index presence check made status report it unindexed and
registry validation skip it. The check now follows the slot's
validated graphPath.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(storage): adopt existing worktree indexes and report shared-store state (#3352)

A shared checkout gets <repo>/.gitnexus/store.json pointing at its store
slot; resolution follows it only when it names that checkout's own slot.
An existing local index seeds the slot and is left in place. status
(text and --json) and doctor report the store, whether the graph is
shared or private, and any leftover local index, which
clean --local-index removes while keeping the pointer. With
GITNEXUS_SHARED_STORE=off a previously shared checkout indexes into its
own .gitnexus again and never writes a commit graph.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(mcp): read the graph a shared checkout points at (#3352)

MCP, the HTTP API, group sync, augmentation, and the Claude hook (all
three byte-identical copies) resolve a flat slot's graph through
resolveGraphPath instead of joining 'lbug' onto the storage path, so
checkouts at one commit share one open database. The embeddings writers
(embeddings sync and the server embed job) take a private copy first
and never write an immutable commit graph. The post-analyze settle
probe accepts fresh metadata that points at an existing commit graph.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: describe the shared worktree index store (#3352)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(storage): reuse helpers across the shared-store code (#3352)

One graph-clone helper replaces two copy-then-rename blocks; clean and
status reuse formatSlotSize; leaving a store reuses
removeSharedStorePointer; withStoreLock is imported from its own module
instead of a re-export.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address review findings in the shared store (#3352)

- Never publish a graph whose build saw dirty files, and never trust a
  local-index seed on the up-to-date path; it may hold reverted edits.
- Record slot pointers and reclaim under the publish lock, and reclaim
  right after each publish, so a commit graph is never deleted between
  publish and pointer save and superseded graphs don't pile up.
- clean --gc decides membership from the registry, so opted-in clones
  and a main checkout without worktrees are not dropped.
- Leaving a store re-registers first, so an up-to-date run cannot leave
  the registry pointing at a deleted slot.
- Drop pinned branch summaries when an entry moves into a store slot.
- Keep run.cjs and the AGENTS.md runner path inside the checkout.
- MCP handles follow the slot's current graph, and branch scoping reads
  the slot's own metadata.
- Cache resolveGraphPath by metadata file identity; look up opted-in
  entries with canonical registry paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): localize help for the shared-store flags (#3352)

--help renders option text from the i18n catalog, so --share-with,
--no-share, clean --gc and clean --local-index need keys in both
locales, not just index.ts literals.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): lock the embed-job graph copy and skip it on forced rebuilds (#3352)

The server embed job copies a shared checkout's graph under the slot's
index lock, so a CLI analyze in another process cannot interleave. A
forced rebuild with no embeddings to carry over drops the pointer
without copying a graph it would discard unread.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(agents): bump AGENTS.md version for the shared store notes (#3352)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): keep shared-store file reads inside checked paths (#3374)

Address CodeQL js/path-injection and js/file-system-race on
shared-store.ts: every filesystem read rebuilds its path under a fixed
parent with an inline path.relative barrier, the .git probe is one read
(EISDIR marks a directory) instead of stat-then-read, and the graph
pointer cache stats and reads through one file descriptor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address review feedback on the shared store (#3374)

- Hold the slot index lock for the whole server embedding job, not
  just the graph copy.
- --no-share re-registers and deletes the old slot under that slot's
  index lock, so a running shared analyze cannot re-register it.
- clean --gc previews without --force, like every other destructive arm.
- status reports a pinned branch index as private.
- Slot names prefix Windows device names that carry an extension.
- A slot or store named ..<name> is a legal direct child.
- Shared-store suites run in the serialized lbug-db vitest project and
  clear an inherited GITNEXUS_SHARED_STORE.
- Doc and test accuracy fixes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(storage): pin shared-store behavior for worktrees of a bare repository (#3374)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address second review round on the shared store (#3374)

- Reject --no-share in a linked worktree before taking any lock or
  indexing anything.
- clean --all and remove also delete each shared checkout's pointer.
- Delete an empty store only while also holding its cache lock, and
  re-check emptiness under it.
- A store pointer is trusted only when the slot's own metadata names
  this checkout; the editable pointer file just says where to look.
- Device names with an extension are prefixed on Windows only, so POSIX
  slot names stay stable.
- Test fixtures use the gitnexus-test- prefix the stale-sidecar sweep
  recognizes; help text and the private-graph label are accurate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address third review round on the shared store (#3374)

- clean --gc never collects a slot whose index lock is held: an analyze
  holds it until it registers the checkout, so a seeded but not yet
  registered slot is busy, not orphaned.
- Reclaim compares absolute paths, so a relative GITNEXUS_HOME does not
  make live commit graphs look unreferenced.
- graphPath is followed only into a published <commit>-<featureKey>
  dir, never .publish-* staging (TS and all three hook copies).
- clean --gc fails on an unreadable stores root instead of reporting
  nothing to collect.
- --no-share help states it is for opted-in clones.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): land the English --no-share help and private-graph label (#3374)

These two strings were meant for e2a9467 and d746675 but were left out
of the commit; the zh-CN side landed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address fifth review round on the shared store (#3374)

- analyze --no-share leaves the shared store even when routed to a
  branch sub-index (gate on sharedStore, not placement.branch)
- clean --gc aborts a store whose member listing is unreadable instead
  of treating it as empty, and skips non-directory entries under stores/
- reusing an existing commit graph no longer fails a finished analyze
  when the redundant private graph cannot be wiped
- a store pointer holding JSON null, a number, a string, or an array is
  treated as invalid
- clean, remove, and DELETE /api/repo hold the checkout slot's index
  lock while deleting the slot
- the server analyze launcher waits on the checkout slot the worker
  actually writes
- sync the Factory hook copy; isolate hook tests from storage overrides;
  use a junction for the Windows worktree alias; the relative
  GITNEXUS_HOME test now sets a relative home

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address sixth review round on the shared store (#3374)

- clean, remove, and analyze --no-share remove the checkout's store
  pointer while still holding the slot's index lock, so an analyze that
  takes the lock next cannot have its new pointer deleted
- clean --gc does not follow a symlink under stores/ (lstat)
- removing a store pointer keeps the checkout's .gitnexus directory when
  it cannot be listed, instead of treating it as empty

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address seventh review round on the shared store (#3374)

- clean --gc aborts when the registry cannot be read instead of treating
  every member as orphaned; with no registry file it collects only
  members whose checkout directory is gone
- seeding from a repository-local index re-reads its metadata under the
  index lock, so the copied graph and the saved metadata match
- clean --gc skips a store another collector removed meanwhile, and
  reports "no shared stores" when stores/ holds only stray files

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address eighth review round on the shared store (#3374)

- removing a checkout's storage unregisters it and removes its store
  pointer before deleting the slot directory, which the file lock
  backend uses for its lock file; withCheckoutSlotLock becomes
  removeCheckoutStorage, used by remove, clean, and DELETE /api/repo,
  and analyze --no-share follows the same order
- the clean --gc preview skips a slot whose index lock is held, matching
  what --force would drop

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(storage): share the index store between clones automatically (#3352)

Clones of one repository now share like linked worktrees. A clone whose
normalized origin URL matches another registered, still-present clone
joins that clone's store, or founds one (keyed on its own path) that the
sibling joins on its next analyze. A lone clone keeps its own .gitnexus.
Graphs stay keyed by commit and feature key, so clones only ever share a
graph built from the same commit with the same settings.

`analyze --no-share` now records a lasting opt-out (`shareOptOut` on the
registry entry, preserved across re-registration); `--share-with` clears
it. The analyze worker reports the storage it wrote over IPC so the
server settles a clone's first shared slot.

A query-time base-plus-overlay graph stays out: LadybugDB reads one
database per query. Instead, private graph copies record whether the
filesystem cloned them copy-on-write (sharing unchanged pages on disk)
or made a full copy, and `status` reports it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): never follow a symlinked checkout .gitnexus (#3374)

A checkout whose `.gitnexus` is a symlink (e.g. `.gitnexus -> ..`) made
the shared-store pointer helpers operate on whatever it pointed at:
findLegacyLocalIndex listed the target as a "legacy index", so
`clean --local-index --force` recursively deleted the checkout's
siblings; writeSharedStorePointer wrote store.json/.gitignore there; and
removeSharedStorePointer deleted store.json there and could rm -r the
target directory.

All four now go through probePointerDir, which lstats `.gitnexus` and
accepts it only when it is a real directory whose realpath is
realpath(checkout)/.gitnexus (mirroring stale-branch-slots.ts). Anything
else is left untouched: no legacy index is reported or removed, and no
pointer is written or removed. removeLegacyLocalIndex re-probes after
sizing, just before its delete loop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): never share a graph from a checkout that hides files (#3374)

Trigger: a sparse checkout, a skip-worktree/assume-unchanged entry, or an
uninitialized submodule leaves `git status` clean, and the run's
indexCoverage.dirtyPaths drops paths with no file hash, so publishSharedGraph
published a graph missing those files as the commit graph. seedSharedSlot
then copied the seed's lastCommit into the new pointer slot, so the next
analyze of a sibling hit the up-to-date fast path without hashing.

Fix: add isWorkingTreePristine (storage/git.ts): the unfiltered
listWorkingTreeDirtyPaths must be empty (null fails closed) and every
gitlink in the index must have a checked-out `.git`. publishSharedGraph
requires it instead of isWorkingTreeDirty. seedSharedSlot keeps the seed's
lastCommit only when the seed is at HEAD and the checkout is pristine, so a
clean sibling still fast-paths onto the shared graph; any other seeded
pointer gets an empty lastCommit, like seedFromLocalIndex, and its next run
hash-diffs, then re-points at the commit graph on publish.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): keep graphs with pending embeddings out of the shared store (#3374)

Trigger: an analyze that finished with embeddings still owed (meta carries
embeddingCheckpoint) was published as the commit graph, because the
feature key ignores the checkpoint and publish never checked it. The
published meta kept the checkpoint while the graph could never change. The
next run healed the embeddings privately, found the commit graph already
there, wiped its own healed graph and pointed back at the partial one.

Fix: publishSharedGraph treats a checkpointed graph as not shareable, so it
stays private and no published commit graph carries a checkpoint. When the
target commit graph exists but records a checkpoint, has fewer
stats.embeddings than the private graph, or has unreadable metadata, the
checkout keeps its private graph instead of re-pointing. The commit graph
is left as is because other checkouts may read it. Embeddings sync and the
server embed job already privatize a pointer slot before writing
(ensurePrivateSharedGraph).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): rebuild a shared slot whose graph is missing (#3374)

A publish interrupted between moving the checkout's graph into
`.publish-<uuid>` staging and renaming staging onto the commit dir left
slot metadata at HEAD with no graph and no graphPath; the next reclaim
deleted the orphaned staging. The up-to-date fast path never checked that
the graph exists, so every later analyze reported "Already up to date"
over a checkout with no index. A failed restore in publishSharedGraph's
catch had the same outcome, and also deleted the staging dir holding the
only copy of the graph.

- run-analyze: for a shared-store slot, stat the graph the checkout reads
  (resolveGraphPath for the flat slot, the branch slot's own lbug
  otherwise) before the fast path; if it is missing, force a full
  rebuild. An incremental run would diff nothing into a fresh, empty
  database. Private .gitnexus indexes are unchanged: they only lose their
  graph by hand, and metadata-only fast-path fixtures rely on that path.
- publishSharedGraph: when moving the staged graph back fails and it is
  still in staging, keep the staging dir, log its path, and skip this
  run's reclaim, which would otherwise delete it. The next analyze finds
  no graph and rebuilds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): fail safe on unreadable slot metadata during reclaim (#3374)

Trigger: reclaim counted commit-graph references with loadMeta, which
returns null for a torn or unreadable gitnexus.json as well as for a
missing one. A member whose metadata could not be read therefore
"referenced nothing", and the graph it still used was deleted. Separately,
in `clean --gc` an orphan slot that fs.rm could not delete threw out of
reclaim, aborting the collection of every store after it.

Fix: the reference loop reads slot metadata with a local loadMetaStrict.
Only absent metadata (ENOENT/ENOTDIR, same legacy-mirror fallback as
loadMeta) means no reference; any other read error or a parse failure
throws with the file path, like listDirStrict. Every caller already
treats a reclaim throw as best effort (analyze logs "skipped cleanup",
slot removal ignores it) or surfaces it (clean --gc). A failed orphan
slot delete is now kept: it stays a member, so the graph it names is
kept too, and it is reported in ReclaimResult.keptMembers and by a new
clean --gc line. loadMeta itself is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(server): unregister under the slot lock and answer 409 on DELETE /api/repo (#3374)

DELETE /api/repo called removeCheckoutStorage(storagePath) with no
unregister callback and no checkout path, swallowed every error, and
unregistered later outside the slot lock. For a shared-store slot this
left the checkout's store.json pointer behind, let an analyze re-register
between the slot removal and the unregister, and reported {deleted} even
when the slot lock could not be taken because an analyze held it.

The handler now calls removeCheckoutStorage(storagePath,
() => unregisterRepo(entry.path), entry.path), matching `gitnexus remove`
and `gitnexus clean`, so the unregister and the pointer removal run under
the slot lock; non-shared storage is still removed, then unregistered.
The later standalone unregister is gone. An IndexLockTimeoutError answers
409 and leaves the entry registered; any other failure propagates to the
handler's 500, also with the entry kept so the delete can be retried,
instead of unregistering over leftover index files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): give concurrent sibling clones one deterministic founder key (#3374)

Trigger: two registered clones of one origin, both still on local storage,
analyzing at the same time each saw no sibling store yet and founded a store
keyed on their own checkout path. registeredStore() then kept each clone in
its own store for good, so they never shared a graph.

Fix: when no sibling store exists, siblingCloneStore keys the new store on
the canonical path that sorts first among this clone and its registered
siblings (case-folded on Windows, like registryPathEquals), so every sibling
computes the same key. An existing sibling store still wins. Clones that
already diverged into two stores are not migrated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): keep subdirectories of a clone out of shared stores (#3374)

Trigger: `analyze --skip-git <clone>/pkg` inside a clone with a registered
sibling of the same origin joined, or founded, a clone store. getRemoteUrl
answers from any subdirectory, so siblingCloneStore saw the enclosing
clone's remote. That broke the shared-store invariant that only tree roots
participate.

Fix: resolveOptedInStore now returns undefined for a path with no `.git`
entry. It uses hasGitDir, which accepts a directory or a linked-worktree
file, the same as resolveSharedStore's readCommonDir gate and run-analyze's
repoHasGit. `--share-with` from such a path throws a clear error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(hooks): prefix Windows device names with an extension in hook slot names (#3374)

Trigger: on Windows, storage-slot.ts sanitizeSlotBasename prefixes a
device-name basename that has an extension (`CON.txt` -> `repository-CON.txt`),
but the hook's copy in registry-query.cjs only matched the bare device name.
With GITNEXUS_STORAGE_ROOT set, a checkout named e.g. `con.txt` got a
different slot from the hook than from the CLI, so the hook could not see its
index.

Fix: mirror the platform branch (extension form on win32, bare form
elsewhere) in all four byte-identical registry-query.cjs copies, and point
their header comments at storage-slot.ts. Add a hook-vs-TS parity test over
device-name basenames on both stubbed platforms, and fix the byte-identity
test title to say four copies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(storage): tidy the shared-store fix series (#3374)

Explain the keptStaging early return where it is checked, reuse the
exported EmbeddingCheckpoint type in the publish test, and drop
review-step labels from test comments. No behavior change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address ninth review round on the shared store (#3374)

- removeCheckoutStorage: keep the lock-safe order (unregister and drop the
  pointer under the slot lock, delete the slot last), but when the final
  rm fails, throw an error that says the checkout was already unregistered,
  names the leftover slot, and points at `gitnexus clean --gc --force`.
- clean --gc preview no longer sweeps staging files: acquireIndexLock
  takes `sweep: false`, which the dry-run reclaim passes.
- listStoreMetaRoots reports completeness; a store directory that cannot
  be listed (other than missing) makes the parse-cache prune retain every
  chunk instead of evicting keys other members still use.
- clean.shared.kept says "could not be removed" rather than "still open".
- ARCHITECTURE.md describes the stores/<key>/ layout and store.json
  pointer; README lists every GITNEXUS_SHARED_STORE off value and that it
  also stops clone sharing.
- Tests: close the direct Ladybug connection in finally; fix a fixture
  JSDoc.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(storage): pin the publish gate for cone and sparse-index checkouts (#3374)

isWorkingTreePristine already rejects every sparse mode, because each one
marks left-out entries skip-worktree and `git ls-files -v` expands a sparse
index. Only no-cone was tested; cover cone and cone with a sparse index too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(storage): name the checks that answer recurring review findings (#3374)

Comment-only. The review bot re-derives findings from the code on every
push, so put the refuting fact next to each flagged line: the pristine
check's hidden-path coverage, sparse checkouts being skip-worktree, the
lock-safe unregister-then-delete order, the hook parity test for device
names, and why the stores/ lstat is not a race guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:20:12 +01:00

1262 lines
53 KiB
TypeScript

/**
* Unit Tests: git utility helpers (storage/git.ts)
*
* Tests isGitRepo, getCurrentCommit, getGitRoot, and the newly added
* hasGitDir helper introduced for issue #384 (indexing non-git folders).
*/
import { describe, it, expect, vi } from 'vitest';
import path from 'path';
import os from 'os';
import fs from 'fs';
import { execFileSync, execSync } from 'child_process';
const gitExecutable = (() => {
if (process.platform !== 'win32') return 'git';
try {
return (
execFileSync('where.exe', ['git'], { encoding: 'utf8' }).split(/\r?\n/).find(Boolean) ?? 'git'
);
} catch {
return 'git';
}
})();
const isolatedTmpRoot = (() => {
const root =
process.platform === 'win32'
? path.join(path.parse(os.tmpdir()).root, 'gitnexus-outside-git')
: path.join(os.tmpdir(), 'gitnexus-outside-git');
fs.mkdirSync(root, { recursive: true });
return root;
})();
const makeIsolatedTempDir = (prefix = 'gitnexus-test-'): string =>
fs.mkdtempSync(path.join(isolatedTmpRoot, prefix));
// ─── hasGitDir ────────────────────────────────────────────────────────────
//
// hasGitDir is a synchronous fs.statSync check — we test it by actually
// creating temporary directories rather than mocking the fs module,
// because the implementation is a simple one-liner and real disk I/O is
// fast and deterministic for this purpose.
describe('hasGitDir', () => {
// Import after test setup to ensure module resolution is correct
const getHasGitDir = async () => {
const mod = await import('../../src/storage/git.js');
return mod.hasGitDir;
};
it('returns true when .git directory exists', async () => {
const hasGitDir = await getHasGitDir();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-'));
try {
fs.mkdirSync(path.join(tmpDir, '.git'));
expect(hasGitDir(tmpDir)).toBe(true);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns true when .git is a file (git worktree)', async () => {
const hasGitDir = await getHasGitDir();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-'));
try {
fs.writeFileSync(path.join(tmpDir, '.git'), 'gitdir: /some/other/.git\n');
expect(hasGitDir(tmpDir)).toBe(true);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns false when .git entry is absent', async () => {
const hasGitDir = await getHasGitDir();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-'));
try {
// No .git here — plain directory
expect(hasGitDir(tmpDir)).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns false for a non-existent path', async () => {
const hasGitDir = await getHasGitDir();
expect(hasGitDir('/tmp/__gitnexus_nonexistent_path__')).toBe(false);
});
});
// ─── isGitRepo ────────────────────────────────────────────────────────────
//
// isGitRepo shells out to `git rev-parse` — we verify it returns false
// for a plain temp directory without running git init.
describe('isGitRepo', () => {
it('returns false for a plain (non-git) directory', async () => {
const { isGitRepo } = await import('../../src/storage/git.js');
const tmpDir = makeIsolatedTempDir();
try {
expect(isGitRepo(tmpDir)).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns false for a non-existent path', async () => {
const { isGitRepo } = await import('../../src/storage/git.js');
expect(isGitRepo('/tmp/__gitnexus_nonexistent__')).toBe(false);
});
});
// ─── getCurrentCommit ─────────────────────────────────────────────────────
describe('getCurrentCommit', () => {
it('returns empty string for a non-git directory', async () => {
const { getCurrentCommit } = await import('../../src/storage/git.js');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-'));
try {
expect(getCurrentCommit(tmpDir)).toBe('');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
// Regression: #1172 — without explicit stdio on execSync, Node forwards
// the child's stderr to the parent process, printing "fatal: not a git
// repository" to the user's terminal even though the error is caught.
it('does not leak git stderr to process.stderr (#1172)', async () => {
const { getCurrentCommit } = await import('../../src/storage/git.js');
// git-init a dir without commits so `git rev-parse HEAD` fails with a
// "fatal:" message — the exact class of error that leaked before the fix.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-'));
execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
try {
expect(getCurrentCommit(tmpDir)).toBe('');
const stderrOutput = spy.mock.calls.map((c) => String(c[0])).join('');
expect(stderrOutput).not.toContain('fatal');
} finally {
spy.mockRestore();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
// ─── getGitRoot ───────────────────────────────────────────────────────────
describe('getGitRoot', () => {
it('returns null for a plain temp directory', async () => {
const { getGitRoot } = await import('../../src/storage/git.js');
const tmpDir = makeIsolatedTempDir();
try {
expect(getGitRoot(tmpDir)).toBeNull();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
// Regression: #1172 -- mirrors the getCurrentCommit stderr test above.
it('does not leak git stderr to process.stderr (#1172)', async () => {
const { getGitRoot } = await import('../../src/storage/git.js');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-'));
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
try {
getGitRoot(tmpDir);
const stderrOutput = spy.mock.calls.map((c) => String(c[0])).join('');
expect(stderrOutput).not.toContain('fatal');
} finally {
spy.mockRestore();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('preserves a trailing-space repository directory name (#2190)', async () => {
const { getGitRoot } = await import('../../src/storage/git.js');
const parentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-space-root-'));
const initDir = path.join(parentDir, 'repo-init');
const repoDir = path.join(parentDir, 'repo ');
try {
fs.mkdirSync(initDir);
execFileSync(gitExecutable, ['init', '-q'], { cwd: initDir, stdio: 'ignore' });
fs.renameSync(initDir, repoDir);
expect(getGitRoot(repoDir)).toBe(path.resolve(repoDir));
} finally {
fs.rmSync(parentDir, { recursive: true, force: true });
}
});
});
// ─── getRemoteUrl ─────────────────────────────────────────────────────────
const setupRepoWithRemote = (remoteUrl: string): string => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-remote-'));
// Use real fs paths and shellouts — the helper itself shells out to
// `git config`, so we need a real git repo for the assertion to be
// meaningful.
execSync('git init -q', { cwd: tmpDir });
execSync(`git remote add origin ${remoteUrl}`, { cwd: tmpDir });
return tmpDir;
};
describe('getRemoteUrl', () => {
it('returns undefined for a non-git directory', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-'));
try {
expect(getRemoteUrl(tmpDir)).toBeUndefined();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns undefined for a git repo with no origin remote', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-'));
try {
execSync('git init -q', { cwd: tmpDir });
expect(getRemoteUrl(tmpDir)).toBeUndefined();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('strips trailing .git and lowercases host for HTTPS remotes', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const tmpDir = setupRepoWithRemote('https://GitHub.COM/Foo/Bar.git');
try {
expect(getRemoteUrl(tmpDir)).toBe('https://github.com/Foo/Bar');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('lowercases host for SCP-style SSH remotes and strips .git', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const tmpDir = setupRepoWithRemote('git@GitHub.com:Foo/Bar.git');
try {
expect(getRemoteUrl(tmpDir)).toBe('git@github.com:Foo/Bar');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns the same fingerprint for two clones of the same repo', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const a = setupRepoWithRemote('https://example.com/foo/bar.git');
const b = setupRepoWithRemote('https://example.com/foo/bar');
try {
expect(getRemoteUrl(a)).toBe(getRemoteUrl(b));
expect(getRemoteUrl(a)).toBeTruthy();
} finally {
fs.rmSync(a, { recursive: true, force: true });
fs.rmSync(b, { recursive: true, force: true });
}
});
});
// ─── credentials in remote URLs (#2914) ──────────────────────────────────
//
// `git config remote.origin.url` hands back whatever CI or a credential
// helper configured, including `https://x-access-token:<token>@host/…`.
// That value is persisted (registry.json, the per-repo meta) and echoed by
// MCP `list_repos`, so it must lose its userinfo at capture time. Every
// credential below is an obviously fake constant.
describe('remote URL credentials (#2914)', () => {
const FAKE_TOKEN = 'ExAmPle-FAKE-SECRET';
it('strips userinfo from an HTTPS remote before it can be persisted', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const tmpDir = setupRepoWithRemote(
`https://x-access-token:${FAKE_TOKEN}@github.com/example/project.git`,
);
try {
expect(getRemoteUrl(tmpDir)).toBe('https://github.com/example/project');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('strips a username-only HTTPS remote (the PAT-as-username form)', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const tmpDir = setupRepoWithRemote(`https://${FAKE_TOKEN}@github.com/example/project.git`);
try {
expect(getRemoteUrl(tmpDir)).toBe('https://github.com/example/project');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('leaves plain HTTPS, SSH-URL and SCP-like remotes untouched', async () => {
const { stripUrlCredentials } = await import('../../src/storage/git.js');
// The SSH forms carry a user NAME, not a secret, and are part of the
// remote's identity — rewriting them would repoint the #2054 fingerprint.
expect(stripUrlCredentials('https://github.com/example/project.git')).toBe(
'https://github.com/example/project.git',
);
expect(stripUrlCredentials('ssh://git@github.com/example/project.git')).toBe(
'ssh://git@github.com/example/project.git',
);
expect(stripUrlCredentials('git@github.com:example/project.git')).toBe(
'git@github.com:example/project.git',
);
// An `@` in the PATH is not userinfo — the authority ends at the first `/`.
expect(stripUrlCredentials('https://github.com/example/pro@ject')).toBe(
'https://github.com/example/pro@ject',
);
});
it('removes a password containing @ whole, leaving no tail behind', async () => {
const { stripUrlCredentials } = await import('../../src/storage/git.js');
expect(stripUrlCredentials(`https://user:pa@ss-${FAKE_TOKEN}@host.example/o/r`)).toBe(
'https://host.example/o/r',
);
});
it('keeps the same fingerprint for credentialed and clean clones of one repo', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const withCreds = setupRepoWithRemote(
`https://x-access-token:${FAKE_TOKEN}@example.com/foo/bar.git`,
);
const clean = setupRepoWithRemote('https://example.com/foo/bar');
try {
expect(getRemoteUrl(withCreds)).toBe(getRemoteUrl(clean));
} finally {
fs.rmSync(withCreds, { recursive: true, force: true });
fs.rmSync(clean, { recursive: true, force: true });
}
});
});
// ─── getCanonicalRepoRoot (#1259) ────────────────────────────────────────
//
// Critical for the worktree-naming bug: when `gitnexus analyze` runs from a
// linked worktree, deriving `repoName` from `path.basename(getGitRoot(cwd))`
// uses the worktree's directory slug instead of the canonical repo's
// basename. `getCanonicalRepoRoot` exists specifically to dereference
// worktrees via `git rev-parse --git-common-dir`.
describe('getCanonicalRepoRoot', () => {
it('returns null for a plain temp directory (not a git repo)', async () => {
const { getCanonicalRepoRoot } = await import('../../src/storage/git.js');
const tmpDir = makeIsolatedTempDir('gitnexus-canonical-');
try {
expect(getCanonicalRepoRoot(tmpDir)).toBeNull();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns null for a non-existent path', async () => {
const { getCanonicalRepoRoot } = await import('../../src/storage/git.js');
expect(getCanonicalRepoRoot('/tmp/__gitnexus_canonical_nonexistent__')).toBeNull();
});
it('returns the repo root when called from a regular (non-worktree) checkout', async () => {
const { getCanonicalRepoRoot } = await import('../../src/storage/git.js');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-canonical-main-'));
try {
execSync('git init -q', { cwd: tmpDir });
// Compare via `path.basename` instead of full-path string equality so
// the test is robust to platform path-format quirks (Windows 8.3 short
// names like `C:\Users\RUNNER~1\…` vs long form `C:\Users\runneradmin\…`,
// macOS `/var/folders/… ↔ /private/var/folders/…`). The basename is the
// only part that registry name derivation actually uses (#1259).
const result = getCanonicalRepoRoot(tmpDir);
expect(result).not.toBeNull();
expect(path.basename(result!)).toBe(path.basename(tmpDir));
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns the CANONICAL repo root when called from inside a linked worktree (#1259)', async () => {
const { getCanonicalRepoRoot, getGitRoot } = await import('../../src/storage/git.js');
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-canonical-wt-'));
try {
execFileSync(gitExecutable, ['init', '-q'], { cwd: repoDir, stdio: 'ignore' });
// `git worktree add` requires at least one commit on a real branch.
execSync('git config user.email "test@example.com"', { cwd: repoDir });
execSync('git config user.name "Test"', { cwd: repoDir });
execSync('git commit --allow-empty -q -m "initial"', { cwd: repoDir });
// Create a linked worktree on a new branch outside the main checkout.
const worktreeDir = path.join(repoDir, 'wt-feature');
execSync(`git worktree add -q -b feature "${worktreeDir}"`, { cwd: repoDir });
// Both calls go through the same git executable, so their path-format
// output is guaranteed consistent — equality between them is the
// stable cross-platform assertion. (Comparing against `realpathSync`
// breaks on Windows where 8.3 short names and long names diverge.)
const fromMain = getCanonicalRepoRoot(repoDir);
const fromWorktree = getCanonicalRepoRoot(worktreeDir);
expect(fromMain).not.toBeNull();
// From inside the worktree: canonical points BACK to the main repo's
// shared `.git`. This is the regression-guard for #1259 — the
// registry name derivation collapses across worktrees.
expect(fromWorktree).toBe(fromMain);
// Basename matches the canonical repo dir (NOT the worktree slug).
expect(path.basename(fromWorktree!)).toBe(path.basename(repoDir));
expect(path.basename(fromWorktree!)).not.toBe('wt-feature');
// Sanity: getGitRoot returns the worktree-local root (existing
// behavior unchanged). Compare basenames for the same path-format
// reason as above.
expect(path.basename(getGitRoot(worktreeDir)!)).toBe('wt-feature');
} finally {
// Best-effort cleanup; worktree teardown can leak open handles on
// Windows so use force.
try {
execSync('git worktree remove -f wt-feature', { cwd: repoDir });
} catch {
// ignore — fall through to recursive rm
}
fs.rmSync(repoDir, { recursive: true, force: true });
}
});
});
// ─── selfCommitContextFiles (#2639) ────────────────────────────────────────
describe('selfCommitContextFiles', () => {
const initRepo = (): string => {
const repoDir = makeIsolatedTempDir('gitnexus-self-commit-');
execFileSync(gitExecutable, ['init', '-q'], { cwd: repoDir, stdio: 'ignore' });
execSync('git config user.email "test@example.com"', { cwd: repoDir });
execSync('git config user.name "Test"', { cwd: repoDir });
return repoDir;
};
const lastCommitMessage = (repoDir: string): string =>
execSync('git log -1 --format=%s', { cwd: repoDir, encoding: 'utf8' }).trim();
const commitCount = (repoDir: string): number =>
Number(execSync('git rev-list --count HEAD', { cwd: repoDir, encoding: 'utf8' }).trim());
const stagedFiles = (repoDir: string): string[] =>
execSync('git diff --cached --name-only', { cwd: repoDir, encoding: 'utf8' })
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
// Most tests below aren't exercising snapshotSelfCommitSafety itself (that
// has its own describe block); they just need "everything is safe to
// commit," matching a normal run where nothing was dirty beforehand.
const allSafe = (names: string[]): Map<string, boolean> =>
new Map(names.map((name) => [name, true]));
it('commits only the changed candidate file, scoped by name (never git add -A)', async () => {
const { selfCommitContextFiles } = await import('../../src/storage/git.js');
const repoDir = initRepo();
try {
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'v1\n');
execSync('git add AGENTS.md', { cwd: repoDir });
execSync('git commit -q -m "initial"', { cwd: repoDir });
// Dirty AGENTS.md (candidate) plus an unrelated untracked file that
// must never be swept in.
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'v2\n');
fs.writeFileSync(path.join(repoDir, 'unrelated.txt'), 'should stay untouched\n');
selfCommitContextFiles(
repoDir,
['AGENTS.md', 'CLAUDE.md'],
allSafe(['AGENTS.md', 'CLAUDE.md']),
);
expect(commitCount(repoDir)).toBe(2);
expect(lastCommitMessage(repoDir)).toBe('chore(gitnexus): refresh index stats [skip ci]');
const status = execSync('git status --porcelain', { cwd: repoDir, encoding: 'utf8' });
// unrelated.txt is still untracked/dirty — proves the commit was scoped.
expect(status).toContain('unrelated.txt');
expect(status).not.toContain('AGENTS.md');
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
});
it('no-ops (no new commit) when neither candidate file changed', async () => {
const { selfCommitContextFiles } = await import('../../src/storage/git.js');
const repoDir = initRepo();
try {
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'v1\n');
execSync('git add AGENTS.md', { cwd: repoDir });
execSync('git commit -q -m "initial"', { cwd: repoDir });
selfCommitContextFiles(
repoDir,
['AGENTS.md', 'CLAUDE.md'],
allSafe(['AGENTS.md', 'CLAUDE.md']),
);
expect(commitCount(repoDir)).toBe(1);
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
});
it('commits newly-created (untracked) candidate files, not just modified ones', async () => {
// Regression guard: a first-time `analyze --self-commit` run creates
// AGENTS.md/CLAUDE.md fresh — they are untracked, not modified. An
// implementation based on `git diff --quiet` misses untracked files
// entirely and would silently skip this case.
const { selfCommitContextFiles } = await import('../../src/storage/git.js');
const repoDir = initRepo();
try {
execSync('git commit -q --allow-empty -m "initial"', { cwd: repoDir });
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'fresh from analyze\n');
selfCommitContextFiles(
repoDir,
['AGENTS.md', 'CLAUDE.md'],
allSafe(['AGENTS.md', 'CLAUDE.md']),
);
expect(commitCount(repoDir)).toBe(2);
expect(lastCommitMessage(repoDir)).toBe('chore(gitnexus): refresh index stats [skip ci]');
const status = execSync('git status --porcelain', { cwd: repoDir, encoding: 'utf8' });
expect(status.trim()).toBe('');
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
});
it('no-ops when neither candidate file exists on disk', async () => {
const { selfCommitContextFiles } = await import('../../src/storage/git.js');
const repoDir = initRepo();
try {
execSync('git commit -q --allow-empty -m "initial"', { cwd: repoDir });
expect(() =>
selfCommitContextFiles(
repoDir,
['AGENTS.md', 'CLAUDE.md'],
allSafe(['AGENTS.md', 'CLAUDE.md']),
),
).not.toThrow();
expect(commitCount(repoDir)).toBe(1);
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
});
it('never throws when repoPath is not a git repository', async () => {
const { selfCommitContextFiles } = await import('../../src/storage/git.js');
const tmpDir = makeIsolatedTempDir('gitnexus-self-commit-nongit-');
try {
fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), 'not a git repo\n');
expect(() =>
selfCommitContextFiles(
tmpDir,
['AGENTS.md', 'CLAUDE.md'],
allSafe(['AGENTS.md', 'CLAUDE.md']),
),
).not.toThrow();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('commits both files when both changed, still scoped (no -A)', async () => {
const { selfCommitContextFiles } = await import('../../src/storage/git.js');
const repoDir = initRepo();
try {
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'v1\n');
fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'v1\n');
execSync('git add AGENTS.md CLAUDE.md', { cwd: repoDir });
execSync('git commit -q -m "initial"', { cwd: repoDir });
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'v2\n');
fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'v2\n');
selfCommitContextFiles(
repoDir,
['AGENTS.md', 'CLAUDE.md'],
allSafe(['AGENTS.md', 'CLAUDE.md']),
);
expect(commitCount(repoDir)).toBe(2);
const status = execSync('git status --porcelain', { cwd: repoDir, encoding: 'utf8' });
expect(status.trim()).toBe('');
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
});
it('logs a warning (never throws) when the commit step fails, e.g. no git identity', async () => {
const { selfCommitContextFiles } = await import('../../src/storage/git.js');
const { _captureLogger } = await import('../../src/core/logger.js');
const repoDir = initRepo();
try {
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'v1\n');
execSync('git add AGENTS.md', { cwd: repoDir });
execSync('git commit -q -m "initial"', { cwd: repoDir });
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'v2\n');
// useConfigOnly forces git to error on a missing identity instead of
// guessing from OS user/hostname; HOME/XDG_CONFIG_HOME are redirected
// and GIT_CONFIG_NOSYSTEM disables the system config, so no ambient
// global identity on the CI runner can leak in and make git succeed
// anyway. Together these deterministically reproduce "no git identity
// configured" regardless of the machine running the test.
execSync('git config user.useConfigOnly true', { cwd: repoDir });
execSync('git config --unset user.name', { cwd: repoDir });
execSync('git config --unset user.email', { cwd: repoDir });
const savedHome = process.env.HOME;
const savedXdg = process.env.XDG_CONFIG_HOME;
const savedNoSystem = process.env.GIT_CONFIG_NOSYSTEM;
process.env.HOME = makeIsolatedTempDir('gitnexus-self-commit-noidentity-home-');
process.env.XDG_CONFIG_HOME = process.env.HOME;
process.env.GIT_CONFIG_NOSYSTEM = '1';
const cap = _captureLogger();
try {
expect(() =>
selfCommitContextFiles(
repoDir,
['AGENTS.md', 'CLAUDE.md'],
allSafe(['AGENTS.md', 'CLAUDE.md']),
),
).not.toThrow();
} finally {
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = savedXdg;
if (savedNoSystem === undefined) delete process.env.GIT_CONFIG_NOSYSTEM;
else process.env.GIT_CONFIG_NOSYSTEM = savedNoSystem;
}
const warning = cap
.records()
.find((r) => r.msg.includes('--self-commit failed to commit context files'));
cap.restore();
expect(warning).toBeDefined();
expect(commitCount(repoDir)).toBe(1); // commit never landed
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
});
it('skips (and logs) a candidate that already had an uncommitted edit before this run, never sweeping it into the generated commit', async () => {
// Regression for #2640 review round 1/2: without snapshotSelfCommitSafety,
// a pre-existing unstaged user edit in AGENTS.md and this run's stats
// refresh are indistinguishable — both just show up as "AGENTS.md is
// dirty" — so the old implementation silently committed both together.
const { selfCommitContextFiles, snapshotSelfCommitSafety } =
await import('../../src/storage/git.js');
const { _captureLogger } = await import('../../src/core/logger.js');
const repoDir = initRepo();
try {
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'v1\n');
fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'v1\n');
execSync('git add AGENTS.md CLAUDE.md', { cwd: repoDir });
execSync('git commit -q -m "initial"', { cwd: repoDir });
// A user edit lands in AGENTS.md BEFORE analyze/self-commit ever runs.
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'user note\n');
// The real call sequence: snapshot safety first (this is what
// analyze.ts does before writing), THEN simulate analyze's own write
// on top of the user's pre-existing edit, for both candidates.
const safety = snapshotSelfCommitSafety(repoDir, ['AGENTS.md', 'CLAUDE.md']);
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'user note\ngenerated stats refresh\n');
fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'generated stats refresh\n');
const cap = _captureLogger();
selfCommitContextFiles(repoDir, ['AGENTS.md', 'CLAUDE.md'], safety);
const warning = cap
.records()
.find((r) => r.msg.includes('skipping file(s) with uncommitted changes'));
cap.restore();
expect(warning).toBeDefined();
// CLAUDE.md was clean pre-run (safe) and got committed; AGENTS.md was
// already dirty pre-run (unsafe) and must stay out of the commit and
// out of the index entirely — proving its edit wasn't swept in.
expect(commitCount(repoDir)).toBe(2);
expect(lastCommitMessage(repoDir)).toBe('chore(gitnexus): refresh index stats [skip ci]');
const diffTreeFiles = execSync('git diff-tree --no-commit-id --name-only -r HEAD', {
cwd: repoDir,
encoding: 'utf8',
})
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
expect(diffTreeFiles).toEqual(['CLAUDE.md']);
const status = execSync('git status --porcelain -- AGENTS.md', {
cwd: repoDir,
encoding: 'utf8',
});
expect(status.trim()).not.toBe(''); // AGENTS.md's edit is still there, untouched
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
});
it('restores the index for exactly the staged files when commit fails after git add (no leftover staged state)', async () => {
// Regression for #2640 review round 2: `git add` runs before `git commit`;
// if commit then fails (e.g. missing identity), the old implementation
// left the candidate staged even though it reported nothing happened —
// silently mutating the user's index on a run that "did nothing."
const { selfCommitContextFiles } = await import('../../src/storage/git.js');
const repoDir = initRepo();
try {
execSync('git commit -q --allow-empty -m "initial"', { cwd: repoDir });
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'fresh from analyze\n');
execSync('git config user.useConfigOnly true', { cwd: repoDir });
execSync('git config --unset user.name', { cwd: repoDir });
execSync('git config --unset user.email', { cwd: repoDir });
const savedHome = process.env.HOME;
const savedXdg = process.env.XDG_CONFIG_HOME;
const savedNoSystem = process.env.GIT_CONFIG_NOSYSTEM;
process.env.HOME = makeIsolatedTempDir('gitnexus-self-commit-noidentity-home2-');
process.env.XDG_CONFIG_HOME = process.env.HOME;
process.env.GIT_CONFIG_NOSYSTEM = '1';
try {
selfCommitContextFiles(
repoDir,
['AGENTS.md', 'CLAUDE.md'],
allSafe(['AGENTS.md', 'CLAUDE.md']),
);
} finally {
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = savedXdg;
if (savedNoSystem === undefined) delete process.env.GIT_CONFIG_NOSYSTEM;
else process.env.GIT_CONFIG_NOSYSTEM = savedNoSystem;
}
expect(commitCount(repoDir)).toBe(1); // commit never landed
expect(stagedFiles(repoDir)).toEqual([]); // and nothing was left staged
// The file itself is still there, unstaged, exactly as analyze left it.
const status = execSync('git status --porcelain -- AGENTS.md', {
cwd: repoDir,
encoding: 'utf8',
});
expect(status.trim()).toBe('?? AGENTS.md');
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
});
});
// ─── isWorkingTreeDirty ───────────────────────────────────────────────────
//
// analyze's fast-path gate. GitNexus writes to .gitnexus/, .claude/, .cursor/,
// AGENTS.md, CLAUDE.md, and the repo-local .agents/ skill mirror during a run;
// those writes must never count as "dirty" or the up-to-date fast path is
// defeated on every re-run. Real temporary git repos exercise the actual
// `git status --porcelain` pathspec exclude list.
/** Create a fresh git repo in an isolated temp dir and return its path. */
function makeIsolatedGitRepo(): string {
const dir = makeIsolatedTempDir('gn-dirty-');
execSync('git init -q', { cwd: dir, stdio: 'ignore' });
// Set a stable identity so commit doesn't fail on environments without
// global git config (CI containers, fresh sandboxes).
execSync('git config user.email t@t', { cwd: dir, stdio: 'ignore' });
execSync('git config user.name t', { cwd: dir, stdio: 'ignore' });
return dir;
}
describe('isWorkingTreeDirty', () => {
it('returns false for a clean tree with only GitNexus-managed paths written', async () => {
const { isWorkingTreeDirty } = await import('../../src/storage/git.js');
const repo = makeIsolatedGitRepo();
try {
// Initial commit so the tree has a HEAD.
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' });
// Simulate GitNexus writing its managed outputs.
fs.mkdirSync(path.join(repo, '.gitnexus'), { recursive: true });
fs.writeFileSync(path.join(repo, '.gitnexus', 'meta.json'), '{}');
fs.mkdirSync(path.join(repo, '.claude', 'skills', 'gitnexus-cli'), { recursive: true });
fs.writeFileSync(path.join(repo, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md'), 'x');
fs.mkdirSync(path.join(repo, '.agents', 'skills', 'gitnexus-area-auth'), {
recursive: true,
});
fs.writeFileSync(path.join(repo, '.agents', 'skills', 'gitnexus-area-auth', 'SKILL.md'), 'x');
fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'x');
fs.writeFileSync(path.join(repo, 'CLAUDE.md'), 'x');
expect(isWorkingTreeDirty(repo)).toBe(false);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
it('returns true when a real source file changes (regression: excludes must not mask real edits)', async () => {
const { isWorkingTreeDirty } = await import('../../src/storage/git.js');
const repo = makeIsolatedGitRepo();
try {
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' });
// A real business-file edit alongside GitNexus writes.
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
fs.writeFileSync(path.join(repo, 'src', 'foo.ts'), 'export const x = 2;');
fs.mkdirSync(path.join(repo, '.agents', 'skills', 'x'), { recursive: true });
fs.writeFileSync(path.join(repo, '.agents', 'skills', 'x', 'SKILL.md'), 'x');
expect(isWorkingTreeDirty(repo)).toBe(true);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
it('treats the entire .agents/ tree as excluded (root file, nested skills, deep paths)', async () => {
const { isWorkingTreeDirty } = await import('../../src/storage/git.js');
const repo = makeIsolatedGitRepo();
try {
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' });
// Root-level file under .agents/.
fs.mkdirSync(path.join(repo, '.agents'), { recursive: true });
fs.writeFileSync(path.join(repo, '.agents', 'foo.txt'), 'x');
// Deep nested mirror path.
fs.mkdirSync(path.join(repo, '.agents', 'skills', 'gitnexus-area-auth'), {
recursive: true,
});
fs.writeFileSync(path.join(repo, '.agents', 'skills', 'gitnexus-area-auth', 'SKILL.md'), 'x');
expect(isWorkingTreeDirty(repo)).toBe(false);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
it('does not error when .agents/ does not exist (no pathspec failure)', async () => {
const { isWorkingTreeDirty } = await import('../../src/storage/git.js');
const repo = makeIsolatedGitRepo();
try {
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' });
expect(isWorkingTreeDirty(repo)).toBe(false);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
it('does not error when .agents is a file rather than a directory', async () => {
const { isWorkingTreeDirty } = await import('../../src/storage/git.js');
const repo = makeIsolatedGitRepo();
try {
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' });
// .agents exists as a regular file (e.g. user created it by mistake).
fs.writeFileSync(path.join(repo, '.agents'), 'not a directory');
// Must not throw; the tree is otherwise clean so it is not dirty.
expect(isWorkingTreeDirty(repo)).toBe(false);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
it('does NOT exclude prefix-colliding names like .agentsrc or .claudefoo', async () => {
// pathspec `:(exclude).agents` must not swallow `.agentsrc` (no path
// separator). A change to such a colliding name still counts as dirty.
const { isWorkingTreeDirty } = await import('../../src/storage/git.js');
const repo = makeIsolatedGitRepo();
try {
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' });
fs.writeFileSync(path.join(repo, '.agentsrc'), 'x');
fs.writeFileSync(path.join(repo, '.claudefoo'), 'x');
expect(isWorkingTreeDirty(repo)).toBe(true);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
it('does NOT exclude a nested .agents/ inside a subdirectory (root-relative pathspec)', async () => {
// `:(exclude).agents` is relative to the repo root; a subdirectory's
// .agents/ is unrelated and must still count as dirty.
const { isWorkingTreeDirty } = await import('../../src/storage/git.js');
const repo = makeIsolatedGitRepo();
try {
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' });
fs.mkdirSync(path.join(repo, 'subdir', '.agents'), { recursive: true });
fs.writeFileSync(path.join(repo, 'subdir', '.agents', 'x'), 'x');
expect(isWorkingTreeDirty(repo)).toBe(true);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
it('returns true (conservative) when called outside a git repository', async () => {
const { isWorkingTreeDirty } = await import('../../src/storage/git.js');
const dir = makeIsolatedTempDir('gn-nongit-');
try {
// No git init — git status fails, and the gate must fail closed (dirty).
expect(isWorkingTreeDirty(dir)).toBe(true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('returns true (conservative) when git is not on PATH', async () => {
// PATH cleared so `git` cannot be found. The catch block must return true
// (fail closed) rather than silently treating the tree as clean — a clean
// false-positive would skip re-indexing of a genuinely-changed repo.
const { isWorkingTreeDirty } = await import('../../src/storage/git.js');
const repo = makeIsolatedGitRepo();
const savedPath = process.env.PATH;
try {
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' });
process.env.PATH = '';
expect(isWorkingTreeDirty(repo)).toBe(true);
} finally {
process.env.PATH = savedPath;
fs.rmSync(repo, { recursive: true, force: true });
}
});
});
describe('listWorkingTreeDirtyPaths', () => {
it('returns an empty list for a clean repository', async () => {
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
const repo = makeIsolatedGitRepo();
try {
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
execFileSync(gitExecutable, ['add', '--', 'README.md'], { cwd: repo, stdio: 'ignore' });
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], {
cwd: repo,
stdio: 'ignore',
});
expect(listWorkingTreeDirtyPaths(repo)).toEqual([]);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
it('returns dirty source paths and omits GitNexus-managed writes', async () => {
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
const repo = makeIsolatedGitRepo();
try {
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' });
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
fs.writeFileSync(path.join(repo, 'src', 'foo.ts'), 'export const x = 1;');
fs.mkdirSync(path.join(repo, '.gitnexus'), { recursive: true });
fs.writeFileSync(path.join(repo, '.gitnexus', 'meta.json'), '{}');
fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'x');
expect(listWorkingTreeDirtyPaths(repo)).toEqual(['src/foo.ts']);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
it('still reports nested lookalikes that are not GitNexus-managed', async () => {
const { isWorkingTreeDirty, listWorkingTreeDirtyPaths } =
await import('../../src/storage/git.js');
const repo = makeIsolatedGitRepo();
try {
fs.mkdirSync(path.join(repo, 'docs'), { recursive: true });
fs.writeFileSync(path.join(repo, 'docs', 'AGENTS.md'), 'project notes');
execFileSync(gitExecutable, ['add', '--', 'docs/AGENTS.md'], { cwd: repo, stdio: 'ignore' });
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], {
cwd: repo,
stdio: 'ignore',
});
fs.writeFileSync(path.join(repo, 'docs', 'AGENTS.md'), 'edited notes');
expect(isWorkingTreeDirty(repo)).toBe(true);
expect(listWorkingTreeDirtyPaths(repo)).toEqual(['docs/AGENTS.md']);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
it('returns null (not an empty list) outside a git repository', async () => {
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
const dir = makeIsolatedTempDir('gn-nongit-paths-');
try {
expect(listWorkingTreeDirtyPaths(dir)).toBeNull();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('preserves non-ASCII, newline, and arrow-shaped filenames exactly', async () => {
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
const repo = makeIsolatedGitRepo();
const names = ['src/ä.ts'];
if (process.platform !== 'win32') {
names.push('src/a -> b.ts', 'src/line\nbreak.ts', 'src/tab\tname.ts', 'src/back\\slash.ts');
}
try {
for (const name of names) {
fs.mkdirSync(path.dirname(path.join(repo, name)), { recursive: true });
fs.writeFileSync(path.join(repo, name), 'before');
}
execFileSync(gitExecutable, ['add', '--', ...names], { cwd: repo, stdio: 'ignore' });
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], { cwd: repo, stdio: 'ignore' });
for (const name of names) fs.writeFileSync(path.join(repo, name), 'after');
expect(listWorkingTreeDirtyPaths(repo)?.sort()).toEqual([...names].sort());
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
it.skipIf(process.platform === 'win32')(
'returns both paths for a rename without parsing filename text',
async () => {
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
const repo = makeIsolatedGitRepo();
const before = 'src/before -> literal.ts';
const after = 'src/after -> literal.ts';
try {
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
fs.writeFileSync(path.join(repo, before), 'content');
execFileSync(gitExecutable, ['add', '--', before], { cwd: repo, stdio: 'ignore' });
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], { cwd: repo, stdio: 'ignore' });
execFileSync(gitExecutable, ['mv', '--', before, after], { cwd: repo, stdio: 'ignore' });
expect(listWorkingTreeDirtyPaths(repo)?.sort()).toEqual([after, before].sort());
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
},
);
it.each(['--assume-unchanged', '--skip-worktree'])(
'includes paths hidden by git update-index %s',
async (flag) => {
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
const repo = makeIsolatedGitRepo();
try {
fs.writeFileSync(path.join(repo, 'hidden.ts'), 'before');
execFileSync(gitExecutable, ['add', '--', 'hidden.ts'], { cwd: repo, stdio: 'ignore' });
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], {
cwd: repo,
stdio: 'ignore',
});
execFileSync(gitExecutable, ['update-index', flag, '--', 'hidden.ts'], {
cwd: repo,
stdio: 'ignore',
});
fs.writeFileSync(path.join(repo, 'hidden.ts'), 'after');
expect(listWorkingTreeDirtyPaths(repo)).toContain('hidden.ts');
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
},
);
it.each(['--assume-unchanged', '--skip-worktree'])(
'omits GitNexus-managed paths hidden by git update-index %s',
async (flag) => {
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
const repo = makeIsolatedGitRepo();
try {
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'before');
execFileSync(gitExecutable, ['add', '--', 'README.md', 'AGENTS.md'], {
cwd: repo,
stdio: 'ignore',
});
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], {
cwd: repo,
stdio: 'ignore',
});
execFileSync(gitExecutable, ['update-index', flag, '--', 'AGENTS.md'], {
cwd: repo,
stdio: 'ignore',
});
fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'after');
expect(listWorkingTreeDirtyPaths(repo)).toEqual([]);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform === 'win32')(
'preserves exact unusual names hidden by index bits, including both bits',
async () => {
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
const repo = makeIsolatedGitRepo();
const names = ['ä.ts', 'a -> b.ts', 'tab\tname.ts', 'line\nbreak.ts'];
try {
for (const name of names) fs.writeFileSync(path.join(repo, name), 'before');
execFileSync(gitExecutable, ['add', '--', ...names], { cwd: repo, stdio: 'ignore' });
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], {
cwd: repo,
stdio: 'ignore',
});
execFileSync(gitExecutable, ['update-index', '--assume-unchanged', '--', names[0]], {
cwd: repo,
stdio: 'ignore',
});
execFileSync(gitExecutable, ['update-index', '--skip-worktree', '--', names[1]], {
cwd: repo,
stdio: 'ignore',
});
execFileSync(gitExecutable, ['update-index', '--assume-unchanged', '--', names[2]], {
cwd: repo,
stdio: 'ignore',
});
execFileSync(gitExecutable, ['update-index', '--skip-worktree', '--', names[2]], {
cwd: repo,
stdio: 'ignore',
});
execFileSync(gitExecutable, ['update-index', '--skip-worktree', '--', names[3]], {
cwd: repo,
stdio: 'ignore',
});
for (const name of names) fs.writeFileSync(path.join(repo, name), 'after');
expect(listWorkingTreeDirtyPaths(repo)?.sort()).toEqual([...names].sort());
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
},
);
});
// ─── 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 });
}
});
});