mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* feat(storage): add configurable index storage and content retention tiers Rebase #3060 onto current origin/main. Keep GITNEXUS_STORAGE_PATH, GITNEXUS_STORAGE_ROOT, and GITNEXUS_CONTENT_RETENTION, and fold in main's FTS skip, embed-session, and help-text updates. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Keep legacy registry rows on the local storage fallback, resolve symlinks before the destructive-path guard, and align hook lookup with CLI branch slugs, branch-slot metadata, and longest-path match. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Only list swept upload directories after a successful removal so callers cannot treat a permission or transient rm failure as gone. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Document that getStoragePath may consult registered storage while this module still does not mutate the global registry. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(storage): close review findings for external indexes and retention Re-inspect ownership under the analyze lock, fail-closed when the registry file is missing, and keep skip-git hook discovery plus retention fields on HTTP/MCP list surfaces. /api/file stays 410 unless contentRetention is full. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(autofix): apply prettier + eslint fixes via /autofix command * Address PR review feedback (#3060) Treat lock-only index dirs as empty, honor HTTP --force storage policy, and prefer registered plus branch-aware slots in hooks and augment. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Keep hook fallbacks inside the current worktree, compare foreign-local slots canonically, and make storage fixtures survive ownership validation. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix macOS hook test expecting realpath'd registry paths. resolveHookRepo returns the written registry path, not a filesystem realpath, so the assertion must match that. * Address gitnexus-check warnings on hook install docs and slot tests. The Cursor troubleshooting list omitted registry-query.cjs, and the writable-slot test only checked that isDirectory exists instead of that the path is a directory. * Align the HTTP catalog source-scan with skippable resolveRepo validation. resolveRepo lists fresh repos with validate: options.validateStorage !== false so DELETE can skip prune; the test still required a literal validate: true. * Harden storage path sinks so CodeQL path-injection and ReDoS alerts clear. Contain every filesystem probe inside the resolved storage slot with the inline path.relative idiom, reject filesystem-root slots, and trim slot basenames in linear time. * Settle bridge stamps before writing so CI size/mtime matches stay stable. LadybugDB can still flush into bridge.lbug after close+rename; persist whole-millisecond mtimes and wait for consecutive stats to agree so a freshly written pair matches. * Type the settled bridge stat as fs.Stats so tsc does not see bigint. Awaited<ReturnType<typeof fsp.stat>> collapsed the bigint overload and broke prepare/typecheck on CI. * Keep the bridge mtime stamp exact so same-size swaps still fail the pair check. Co-authored-by: Cursor <cursoragent@cursor.com> * Wrap the bridge stamp predicate so prettier --check stays green. Co-authored-by: Cursor <cursoragent@cursor.com> * Require a quiet interval before stamping a settled bridge file. Co-authored-by: Cursor <cursoragent@cursor.com> * Reuse shared storage and settle helpers instead of local copies. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
449 lines
16 KiB
TypeScript
449 lines
16 KiB
TypeScript
/**
|
|
* Unit tests for branch-aware `list` and `status` rendering (#2106).
|
|
*
|
|
* The data layer (repo-manager / git) is mocked so these assert only the
|
|
* console rendering: branch indexes appear when present, single-branch output
|
|
* is unchanged, and `status` reflects the checked-out branch.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import fs from 'fs/promises';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
|
|
const { runnerIdentity } = vi.hoisted(() => ({
|
|
runnerIdentity: {
|
|
schemaVersion: 4 as const,
|
|
runtime: {
|
|
executablePath: '/usr/bin/node',
|
|
version: 'v22.0.0',
|
|
platform: 'linux',
|
|
architecture: 'x64',
|
|
modulesAbi: '127',
|
|
libc: 'glibc:2.39',
|
|
},
|
|
cliVersion: '1.6.9',
|
|
invokedArtifact: { path: '/opt/gitnexus/dist/cli/index.js', digest: 'sha256:entry' },
|
|
build: {
|
|
kind: 'distribution' as const,
|
|
rootPath: '/opt/gitnexus/dist',
|
|
canonicalization: 'gitnexus-analyzer-build-v2' as const,
|
|
digest: 'sha256:build',
|
|
},
|
|
dependencyRuntime: {
|
|
manifestPath: '/opt/gitnexus/package.json',
|
|
lockfilePath: '/opt/package-lock.json',
|
|
canonicalization: 'gitnexus-analyzer-dependency-runtime-v4' as const,
|
|
packageCount: 42,
|
|
artifactCount: 12,
|
|
digest: 'sha256:dependencies',
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/storage/repo-manager.js', () => ({
|
|
listRegisteredRepos: vi.fn(),
|
|
getStoragePaths: vi.fn((repoPath: string, branch?: string, resolvedStoragePath?: string) => ({
|
|
storagePath: resolvedStoragePath ?? `${repoPath}/.gitnexus`,
|
|
lbugPath: branch
|
|
? `${resolvedStoragePath ?? `${repoPath}/.gitnexus`}/branches/${branch}/lbug`
|
|
: `${resolvedStoragePath ?? `${repoPath}/.gitnexus`}/lbug`,
|
|
metaPath: branch
|
|
? `${resolvedStoragePath ?? `${repoPath}/.gitnexus`}/branches/${branch}/meta.json`
|
|
: `${resolvedStoragePath ?? `${repoPath}/.gitnexus`}/meta.json`,
|
|
})),
|
|
loadMeta: vi.fn(),
|
|
hasKuzuIndex: vi.fn().mockResolvedValue(false),
|
|
readRegistryStrict: vi.fn(),
|
|
resolveRegistryEntry: vi.fn(),
|
|
RegistryNotFoundError: class RegistryNotFoundError extends Error {},
|
|
RegistryAmbiguousTargetError: class RegistryAmbiguousTargetError extends Error {},
|
|
}));
|
|
|
|
vi.mock('../../src/core/analyzer-identity.js', () => ({
|
|
resolveAnalyzerRunnerIdentity: vi.fn(() => runnerIdentity),
|
|
analyzerRunnerIdentitiesEqual: vi.fn(
|
|
(indexedIdentity: unknown, currentIdentity: unknown) => indexedIdentity === currentIdentity,
|
|
),
|
|
}));
|
|
|
|
vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../src/storage/storage-resolver.js')>()),
|
|
requireStoragePath: vi.fn().mockResolvedValue('/repo/.gitnexus'),
|
|
requireRegisteredStoragePath: vi.fn().mockResolvedValue('/repo/.gitnexus'),
|
|
}));
|
|
|
|
vi.mock('../../src/storage/git.js', () => ({
|
|
isGitRepo: vi.fn().mockReturnValue(true),
|
|
getCurrentCommit: vi.fn().mockReturnValue('headsha0'),
|
|
getCurrentBranch: vi.fn().mockReturnValue('main'),
|
|
getGitRoot: vi.fn().mockReturnValue('/repo'),
|
|
isWorkingTreeDirty: vi.fn().mockReturnValue(false),
|
|
listWorkingTreeDirtyPaths: vi.fn().mockReturnValue([]),
|
|
}));
|
|
|
|
import { listCommand } from '../../src/cli/list.js';
|
|
import { statusCommand } from '../../src/cli/status.js';
|
|
import {
|
|
listRegisteredRepos,
|
|
loadMeta,
|
|
readRegistryStrict,
|
|
resolveRegistryEntry,
|
|
} from '../../src/storage/repo-manager.js';
|
|
import { getCurrentBranch, getCurrentCommit, isWorkingTreeDirty } from '../../src/storage/git.js';
|
|
import {
|
|
requireRegisteredStoragePath,
|
|
requireStoragePath,
|
|
STATUS_STORAGE_REQUIREMENTS,
|
|
StorageRequirementError,
|
|
} from '../../src/storage/storage-resolver.js';
|
|
|
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
(loadMeta as any).mockResolvedValue({
|
|
repoPath: '/repo',
|
|
lastCommit: 'headsha0',
|
|
indexedAt: '2026-06-10T12:00:00.000Z',
|
|
branch: 'main',
|
|
runnerIdentity,
|
|
scopeExtractionReceipt: 1 as const,
|
|
});
|
|
});
|
|
|
|
describe('list branch rendering (#2106)', () => {
|
|
it('renders the primary branch and a Branch indexes section', async () => {
|
|
(listRegisteredRepos as any).mockResolvedValue([
|
|
{
|
|
name: 'multi',
|
|
path: '/repo',
|
|
storagePath: '/repo/.gitnexus',
|
|
indexedAt: '2026-06-10T12:00:00.000Z',
|
|
lastCommit: 'aaaaaaa1',
|
|
branch: 'main',
|
|
branches: [
|
|
{ branch: 'feature/x', indexedAt: '2026-06-10T13:00:00.000Z', lastCommit: 'bbbbbbb2' },
|
|
],
|
|
stats: { files: 1, nodes: 2, edges: 3 },
|
|
},
|
|
]);
|
|
|
|
await listCommand();
|
|
const out = output();
|
|
expect(out).toContain('Branch: main');
|
|
expect(out).toContain('Branch indexes:');
|
|
expect(out).toContain('feature/x');
|
|
expect(out).toContain('bbbbbbb'); // 7-char short commit of bbbbbbb2
|
|
});
|
|
|
|
it('single-branch entry renders no branch lines (unchanged)', async () => {
|
|
(listRegisteredRepos as any).mockResolvedValue([
|
|
{
|
|
name: 'solo',
|
|
path: '/solo',
|
|
storagePath: '/solo/.gitnexus',
|
|
indexedAt: '2026-06-10T12:00:00.000Z',
|
|
lastCommit: 'ccccccc3',
|
|
stats: { files: 1, nodes: 1, edges: 1 },
|
|
},
|
|
]);
|
|
|
|
await listCommand();
|
|
const out = output();
|
|
expect(out).not.toContain('Branch:');
|
|
expect(out).not.toContain('Branch indexes:');
|
|
});
|
|
});
|
|
|
|
describe('status branch rendering (#2106)', () => {
|
|
const baseRepo = {
|
|
repoPath: '/repo',
|
|
storagePath: '/repo/.gitnexus',
|
|
lbugPath: '/repo/.gitnexus/lbug',
|
|
metaPath: '/repo/.gitnexus/meta.json',
|
|
meta: {
|
|
repoPath: '/repo',
|
|
lastCommit: 'headsha0',
|
|
indexedAt: '2026-06-10T12:00:00.000Z',
|
|
branch: 'main',
|
|
runnerIdentity,
|
|
scopeExtractionReceipt: 1 as const,
|
|
},
|
|
};
|
|
|
|
it.each([false, true])('uses the strict registry reader for status --repo%s', async (json) => {
|
|
const corrupt = new Error('registry is corrupt: entry 0 is malformed');
|
|
vi.mocked(readRegistryStrict).mockRejectedValueOnce(corrupt);
|
|
|
|
await expect(statusCommand({ repo: 'demo', json })).rejects.toBe(corrupt);
|
|
expect(resolveRegistryEntry).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('resolves the Git root before checking the default storage path', async () => {
|
|
(loadMeta as any).mockResolvedValue(baseRepo.meta);
|
|
|
|
await statusCommand({ json: true });
|
|
|
|
expect(requireStoragePath).toHaveBeenCalledWith('/repo', STATUS_STORAGE_REQUIREMENTS);
|
|
});
|
|
|
|
it('checks the exact registry storage path for status --repo', async () => {
|
|
const entry = { path: '/repo', storagePath: '/external/repo-slot' };
|
|
(readRegistryStrict as any).mockResolvedValue([entry]);
|
|
(resolveRegistryEntry as any).mockReturnValue(entry);
|
|
(requireRegisteredStoragePath as any).mockResolvedValue(entry.storagePath);
|
|
(loadMeta as any).mockResolvedValue({ ...baseRepo.meta, repoPath: entry.path });
|
|
|
|
await statusCommand({ repo: 'repo', json: true });
|
|
|
|
expect(requireRegisteredStoragePath).toHaveBeenCalledWith(entry, STATUS_STORAGE_REQUIREMENTS);
|
|
expect(JSON.parse(output())).toMatchObject({ storagePath: entry.storagePath });
|
|
});
|
|
|
|
it.each(['symbol', 'none'] as const)(
|
|
'status --repo reports source-unavailable when checkout exists but contentRetention is %s',
|
|
async (contentRetention) => {
|
|
const checkout = await fs.mkdtemp(path.join(os.tmpdir(), 'gnx-status-src-'));
|
|
try {
|
|
const entry = { path: checkout, storagePath: '/external/repo-slot' };
|
|
(readRegistryStrict as any).mockResolvedValue([entry]);
|
|
(resolveRegistryEntry as any).mockReturnValue(entry);
|
|
(requireRegisteredStoragePath as any).mockResolvedValue(entry.storagePath);
|
|
(loadMeta as any).mockResolvedValue({
|
|
...baseRepo.meta,
|
|
repoPath: entry.path,
|
|
contentRetention,
|
|
});
|
|
|
|
await statusCommand({ repo: 'repo', json: true });
|
|
|
|
expect(JSON.parse(output())).toMatchObject({
|
|
sourceAvailable: false,
|
|
status: 'source-unavailable',
|
|
index: { contentRetention },
|
|
});
|
|
} finally {
|
|
await fs.rm(checkout, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it('status --repo reports sourceAvailable when checkout exists and retention is full', async () => {
|
|
const checkout = await fs.mkdtemp(path.join(os.tmpdir(), 'gnx-status-full-'));
|
|
try {
|
|
const entry = { path: checkout, storagePath: '/external/repo-slot' };
|
|
(readRegistryStrict as any).mockResolvedValue([entry]);
|
|
(resolveRegistryEntry as any).mockReturnValue(entry);
|
|
(requireRegisteredStoragePath as any).mockResolvedValue(entry.storagePath);
|
|
(loadMeta as any).mockResolvedValue({
|
|
...baseRepo.meta,
|
|
repoPath: entry.path,
|
|
contentRetention: 'full',
|
|
});
|
|
|
|
await statusCommand({ repo: 'repo', json: true });
|
|
|
|
expect(JSON.parse(output())).toMatchObject({
|
|
sourceAvailable: true,
|
|
status: 'registered',
|
|
});
|
|
} finally {
|
|
await fs.rm(checkout, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('rejects a foreign registered storage path instead of treating it as an index', async () => {
|
|
const entry = { path: '/repo', storagePath: '/external/repo-slot' };
|
|
const inspection = {
|
|
repoPath: entry.path,
|
|
storagePath: entry.storagePath,
|
|
state: 'foreign' as const,
|
|
hasCodeIndexDB: true,
|
|
};
|
|
(readRegistryStrict as any).mockResolvedValue([entry]);
|
|
(resolveRegistryEntry as any).mockReturnValue(entry);
|
|
(requireRegisteredStoragePath as any).mockRejectedValue(
|
|
new StorageRequirementError(inspection, STATUS_STORAGE_REQUIREMENTS),
|
|
);
|
|
|
|
await expect(statusCommand({ repo: 'repo', json: true })).rejects.toBeInstanceOf(
|
|
StorageRequirementError,
|
|
);
|
|
});
|
|
|
|
it('renders indexed and current typed runner receipts for exact comparison', async () => {
|
|
(loadMeta as any).mockResolvedValue(baseRepo.meta);
|
|
(getCurrentBranch as any).mockReturnValue('main');
|
|
(getCurrentCommit as any).mockReturnValue('headsha0');
|
|
|
|
await statusCommand();
|
|
const out = output();
|
|
expect(out).toContain(`Indexed analyzer runner identity: ${JSON.stringify(runnerIdentity)}`);
|
|
expect(out).toContain(`Current analyzer runner identity: ${JSON.stringify(runnerIdentity)}`);
|
|
});
|
|
|
|
it('renders stable machine-readable provenance with --json', async () => {
|
|
(loadMeta as any).mockResolvedValue(baseRepo.meta);
|
|
(getCurrentBranch as any).mockReturnValue('main');
|
|
(getCurrentCommit as any).mockReturnValue('headsha0');
|
|
|
|
await statusCommand({ json: true });
|
|
const parsed = JSON.parse(output());
|
|
expect(parsed).toMatchObject({
|
|
schemaVersion: 1,
|
|
repository: '/repo',
|
|
index: { commit: 'headsha0', runnerIdentity, runnerIdentityStatus: 'current' },
|
|
current: { commit: 'headsha0', runnerIdentity },
|
|
status: 'up-to-date',
|
|
});
|
|
});
|
|
|
|
it('reports a dirty working tree as stale in --json even when the commit matches', async () => {
|
|
(loadMeta as any).mockResolvedValue(baseRepo.meta);
|
|
(getCurrentBranch as any).mockReturnValue('main');
|
|
(getCurrentCommit as any).mockReturnValue('headsha0');
|
|
(isWorkingTreeDirty as any).mockReturnValueOnce(true);
|
|
|
|
await statusCommand({ json: true });
|
|
expect(JSON.parse(output())).toMatchObject({
|
|
index: { commit: 'headsha0' },
|
|
current: { commit: 'headsha0' },
|
|
status: 'stale',
|
|
});
|
|
});
|
|
|
|
it('reports a dirty working tree as stale in the human output at a matching commit', async () => {
|
|
(loadMeta as any).mockResolvedValue(baseRepo.meta);
|
|
(getCurrentBranch as any).mockReturnValue('main');
|
|
(getCurrentCommit as any).mockReturnValue('headsha0');
|
|
(isWorkingTreeDirty as any).mockReturnValueOnce(true);
|
|
|
|
await statusCommand();
|
|
expect(output()).not.toContain('up-to-date');
|
|
});
|
|
|
|
it('never certifies dirty or checkpointed metadata and reports stable incomplete reasons', async () => {
|
|
(loadMeta as any).mockResolvedValue({
|
|
...baseRepo.meta,
|
|
incrementalInProgress: { startedAt: 1, toWriteCount: 2 },
|
|
embeddingCheckpoint: {
|
|
at: '2026-07-18T00:00:00.000Z',
|
|
nodesProcessed: 1,
|
|
totalNodes: 2,
|
|
chunksProcessed: 1,
|
|
model: 'fixture',
|
|
dimensions: 3,
|
|
provider: 'local',
|
|
},
|
|
});
|
|
(getCurrentBranch as any).mockReturnValue('main');
|
|
(getCurrentCommit as any).mockReturnValue('headsha0');
|
|
|
|
await statusCommand({ json: true });
|
|
expect(JSON.parse(output())).toMatchObject({
|
|
index: {
|
|
incompleteReasons: ['incremental-in-progress', 'embedding-checkpoint-pending'],
|
|
runnerIdentityStatus: 'current',
|
|
},
|
|
status: 'stale',
|
|
});
|
|
});
|
|
|
|
it('treats an older runner receipt schema as stale at the same commit', async () => {
|
|
(loadMeta as any).mockResolvedValue({
|
|
...baseRepo.meta,
|
|
runnerIdentity: { ...runnerIdentity, schemaVersion: 1 },
|
|
});
|
|
(getCurrentBranch as any).mockReturnValue('main');
|
|
(getCurrentCommit as any).mockReturnValue('headsha0');
|
|
|
|
await statusCommand({ json: true });
|
|
expect(JSON.parse(output())).toMatchObject({
|
|
index: { runnerIdentityStatus: 'stale-or-unknown' },
|
|
status: 'stale',
|
|
});
|
|
});
|
|
|
|
it('shows the current branch and up-to-date on the primary', async () => {
|
|
(loadMeta as any).mockResolvedValue(baseRepo.meta);
|
|
(getCurrentBranch as any).mockReturnValue('main');
|
|
(getCurrentCommit as any).mockReturnValue('headsha0');
|
|
|
|
await statusCommand();
|
|
const out = output();
|
|
expect(out).toContain('Branch: main');
|
|
expect(out).toContain('up-to-date');
|
|
});
|
|
|
|
it('falls through to the workspace index when the branch has no pinned index (#2354)', async () => {
|
|
(loadMeta as any).mockResolvedValueOnce(baseRepo.meta).mockResolvedValueOnce(null); // feature/y has no pinned index
|
|
(getCurrentBranch as any).mockReturnValue('feature/y');
|
|
(getCurrentCommit as any).mockReturnValue('headsha9');
|
|
|
|
await statusCommand();
|
|
const out = output();
|
|
expect(out).toContain('Branch: feature/y');
|
|
// The flat workspace index (last analyzed on main) is reported, with the
|
|
// commit comparison saying it lags this branch's tree.
|
|
expect(out).toContain("Workspace index: last analyzed on 'main'");
|
|
expect(out).toContain('stale');
|
|
});
|
|
|
|
it('same-commit branch flip reports up-to-date against the workspace index (#2354)', async () => {
|
|
(loadMeta as any).mockResolvedValueOnce(baseRepo.meta).mockResolvedValueOnce(null); // feature/y has no pinned index
|
|
(getCurrentBranch as any).mockReturnValue('feature/y');
|
|
(getCurrentCommit as any).mockReturnValue('headsha0'); // same commit as flat meta
|
|
|
|
await statusCommand();
|
|
const out = output();
|
|
expect(out).toContain("Workspace index: last analyzed on 'main'");
|
|
expect(out).toContain('up-to-date');
|
|
});
|
|
|
|
it('compares against the branch index when the current branch has one', async () => {
|
|
(loadMeta as any).mockResolvedValueOnce(baseRepo.meta).mockResolvedValueOnce({
|
|
repoPath: '/repo',
|
|
lastCommit: 'zzzzsha0',
|
|
indexedAt: '2026-06-10T14:00:00.000Z',
|
|
branch: 'feature/z',
|
|
runnerIdentity,
|
|
scopeExtractionReceipt: 1,
|
|
});
|
|
(getCurrentBranch as any).mockReturnValue('feature/z');
|
|
(getCurrentCommit as any).mockReturnValue('zzzzsha0');
|
|
|
|
await statusCommand();
|
|
const out = output();
|
|
expect(out).toContain('Branch: feature/z');
|
|
expect(out).toContain('up-to-date');
|
|
});
|
|
|
|
it('shows detached HEAD and compares against the flat index', async () => {
|
|
(loadMeta as any).mockResolvedValue(baseRepo.meta);
|
|
(getCurrentBranch as any).mockReturnValue(null); // detached
|
|
(getCurrentCommit as any).mockReturnValue('headsha0');
|
|
|
|
await statusCommand();
|
|
const out = output();
|
|
expect(out).toContain('(detached HEAD)');
|
|
expect(out).toContain('up-to-date');
|
|
});
|
|
|
|
it('reports stale when the branch index is behind the branch tip', async () => {
|
|
(loadMeta as any).mockResolvedValueOnce(baseRepo.meta).mockResolvedValueOnce({
|
|
repoPath: '/repo',
|
|
lastCommit: 'oldsha00',
|
|
indexedAt: '2026-06-10T14:00:00.000Z',
|
|
branch: 'feature/z',
|
|
});
|
|
(getCurrentBranch as any).mockReturnValue('feature/z');
|
|
(getCurrentCommit as any).mockReturnValue('newsha99'); // moved past the index
|
|
|
|
await statusCommand();
|
|
const out = output();
|
|
expect(out).toContain('Branch: feature/z');
|
|
expect(out).toContain('stale');
|
|
});
|
|
});
|