GitNexus/gitnexus/test/integration/group/group-cli.test.ts
Gergo Magyar ed0c0b9a1d fix(group)!: honour --exact-only, drop inert --allow-stale, report every matching stage
Addresses the review findings on #3020, all of which are the same defect the PR
itself is about: group-sync surface that describes behaviour the pipeline does
not have.

`exactOnly` was inert in exactly the way `skipEmbeddings` was — declared on
`SyncOptions`, threaded through the CLI and the MCP tool, and read by nothing —
and strictly worse, because the stage it promised to suppress DOES run and DOES
write `matchType:'wildcard'` links into contracts.json and the bridge, which
`group impact` and cross-repo `trace` then traverse. It is now honoured rather
than deleted: unlike the never-built BM25/embedding stages, the stage it names
exists, so the flag describes a real choice. The substituted result is
`{ matched: [], remaining: unmatched }`, not an empty result — `wildcard.remaining`
IS `SyncResult.unmatched`, so skipping the stage has to leave its input unmatched
rather than dropping it from the count an operator reads.

`allowStale` had no such stage to gate: `syncGroup` emits no stale warning at any
point (the `checkStaleness` call lives in `groupStatus`, a different path), so it
is removed under the same rationale as `skipEmbeddings`.

`group sync` now prints every matching stage instead of `exact` alone. The old
block printed a `Matching cascade:` header and counted only exact links while the
next line reported `result.crossLinks.length` — which also includes `manifest` and
`wildcard` — so for any group with those the two numbers disagreed with nothing on
screen explaining why. Counting is an exhaustive `Record<MatchType, number>`, so a
new MatchType fails the build here instead of going silently uncounted, and reads
through `?? 0` so a legacy registry carrying a removed matchType prints an honest
count rather than `NaN`.

Also: the MCP `group_sync` description no longer omits the wildcard stage that
always runs, `exactOnly`'s description no longer refers to a "cascade", and
bench/cross-repo-trace/verify.mjs no longer generates the removed threshold keys
into a fresh group.yaml.

Tests: `sync-exact-only.test.ts` pins both directions of the gate (mutation-verified:
removing the gate, or returning `remaining: []`, both go red). `group-tools.test.ts`
pins that the MCP schema dropped `skipEmbeddings` and kept `exactOnly`.
`group-cli.test.ts` pins that both removed flags are rejected, with `--exact-only`
as an accepted-flag control. `config-parser.test.ts` now pins that legacy keys are
PRESERVED (measured, not assumed) rather than only that parsing does not throw.

The type narrowing's fallout in test files is cleared: `tsc -p tsconfig.test.json`
is 987 errors at head against 987 measured on origin/main, with the two error sets
identical — zero net, zero new, zero masked.

Verification: `tsc --noEmit` exit 0; prettier clean; eslint 0 errors (2 warnings,
both pre-existing on base); 69 test files / 1169 tests green across
test/unit/group, test/integration/group, tools, cli-i18n and cli-index-help.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 12:52:17 +00:00

614 lines
23 KiB
TypeScript

/**
* Smoke-test `gitnexus group` CLI (same spawn pattern as cli-e2e.test.ts, via
* CLI_SPAWN_PREFIX: built dist in CI, tsx-on-source locally).
* Does not exercise LadybugDB-backed QUERY commands end-to-end (needs indexed
* fixtures). `group sync` IS driven end-to-end below, but only through the two
* shapes that need no indexed repo: a group whose members are absent from the
* registry, and a group whose members are registered at a storage path holding
* no `lbug` file at all — which is what makes them unreadable.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { CLI_SPAWN_PREFIX } from '../../helpers/cli-entry.js';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import os from 'node:os';
import { INDEX_METADATA_FILE } from '../../../src/storage/repo-meta.js';
const testDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDir, '../../..');
let tmpHome: string;
beforeAll(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-cli-'));
});
afterAll(() => {
if (tmpHome && fs.existsSync(tmpHome)) {
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
function runGroupIn(home: string, args: string[]) {
return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, 'group', ...args], {
cwd: repoRoot,
encoding: 'utf8',
timeout: 20000,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, GITNEXUS_HOME: home },
});
}
function runGroup(args: string[]) {
return runGroupIn(tmpHome, args);
}
describe('group CLI', () => {
it('create + list', () => {
const c = runGroup(['create', 'acme']);
expect(c.status).toBe(0);
expect(c.stdout).toContain('Created group "acme"');
const l = runGroup(['list']);
expect(l.status).toBe(0);
expect(l.stdout).toContain('acme');
});
it('test_create_with_invalid_name_fails', () => {
const result = runGroup(['create', '../../evil']);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('Invalid group name');
});
it('test_sync_command_source_does_not_call_blanket_closeLbug', () => {
const cliGroupPath = path.join(repoRoot, 'src', 'cli', 'group.ts');
const source = fs.readFileSync(cliGroupPath, 'utf-8');
// closeLbug() without arguments (blanket close) must not appear.
// Match closeLbug() but not closeLbug(someArg)
const blanketClosePattern = /closeLbug\s*\(\s*\)/;
expect(source).not.toMatch(blanketClosePattern);
});
/**
* `--skip-embeddings` and `--allow-stale` were both accepted by commander and
* then read by nothing: the first named a BM25/embedding cascade that was
* never built, the second a stale-index warning that no sync path ever
* emitted. An operator who passed either got a silent no-op and a clean exit,
* which is worse than the flag not existing — so they are gone, and the CLI
* must now say so.
*
* `unknown option` is asserted rather than just a nonzero exit because
* `group sync <missing-group>` ALSO exits nonzero (GroupNotFoundError), so
* the exit code alone cannot tell "the flag is rejected" from "the group is
* not there". The control below is what makes that distinction visible.
*/
it('test_sync_rejects_removed_skip_embeddings_flag', () => {
const r = runGroup(['sync', 'acme', '--skip-embeddings']);
expect(r.status).not.toBe(0);
expect(r.stderr).toContain("unknown option '--skip-embeddings'");
});
it('test_sync_rejects_removed_allow_stale_flag', () => {
const r = runGroup(['sync', 'acme', '--allow-stale']);
expect(r.status).not.toBe(0);
expect(r.stderr).toContain("unknown option '--allow-stale'");
});
it('control: the surviving --exact-only flag is still parsed', () => {
// Without this, the two cases above would also pass against a `group sync`
// that rejected EVERY option. This one reaches the action handler and
// fails on the group instead, which is the proof that commander accepted
// the flag itself.
const r = runGroup(['sync', 'no-such-group', '--exact-only']);
expect(r.stderr).not.toContain('unknown option');
expect(`${r.stderr}\n${r.stdout}`).toContain('no-such-group');
});
it('group impact requires --target and --repo', () => {
const c = runGroup(['create', 'impcli']);
expect(c.status).toBe(0);
const r = runGroup(['impact', 'impcli']);
expect(r.status).not.toBe(0);
});
it('group impact runs with Issue #794 style flags (fixture-backed home)', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cli-impact-'));
try {
const gd = path.join(home, 'groups', 'test-group');
fs.mkdirSync(gd, { recursive: true });
fs.copyFileSync(
path.join(repoRoot, 'test', 'fixtures', 'group', 'group.yaml'),
path.join(gd, 'group.yaml'),
);
const r = spawnSync(
process.execPath,
[
...CLI_SPAWN_PREFIX,
'group',
'impact',
'test-group',
'--target',
'health',
'--repo',
'app/backend',
'--json',
],
{
cwd: repoRoot,
encoding: 'utf8',
timeout: 20000,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, GITNEXUS_HOME: home },
},
);
expect(r.status).not.toBe(0);
const msg = `${r.stderr}\n${r.stdout}`;
expect(msg).toMatch(/error|indexed|not found|repository/i);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
});
describe('group contracts reports its completeness', () => {
/**
* `groupContracts` returns the structured triple alongside the contracts, so
* an agent can tell a complete listing from a floor. The `--json` path used
* to destructure `{ contracts, crossLinks }` and re-serialize just those two,
* which silently dropped every other field the service returned — including
* the ones that say the listing is incomplete. Printing the payload whole is
* what keeps a new field from needing a matching CLI edit to become visible.
*/
const seedRegistry = (group: string, registry: Record<string, unknown>): void => {
const groupDir = path.join(tmpHome, 'groups', group);
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(registry, null, 2));
};
const baseRegistry = {
version: 1,
generatedAt: '2026-01-01T00:00:00.000Z',
contracts: [],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
};
it('carries the incompleteness fields through --json', () => {
expect(runGroup(['create', 'jsonfloor']).status).toBe(0);
seedRegistry('jsonfloor', { ...baseRegistry, unreadableRepos: ['app/backend'] });
const r = runGroup(['contracts', 'jsonfloor', '--json']);
expect(r.status).toBe(0);
const payload = JSON.parse(r.stdout) as Record<string, unknown>;
expect(payload.unreadableRepos).toEqual(['app/backend']);
expect(payload.truncated).toBe(true);
expect(payload.truncationReason).toBe('incomplete-sync');
expect(payload.riskEpistemic).toBe('lower-bound');
// Still everything it always returned.
expect(payload.contracts).toEqual([]);
expect(payload.crossLinks).toEqual([]);
});
it('tells a human reader the listing is a floor, and which repos are missing from it', () => {
expect(runGroup(['create', 'humanfloor']).status).toBe(0);
seedRegistry('humanfloor', { ...baseRegistry, unreadableRepos: ['app/backend'] });
const r = runGroup(['contracts', 'humanfloor']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('app/backend');
expect(r.stdout.toLowerCase()).toContain('incomplete');
});
it('control: a complete registry says nothing about truncation on either surface', () => {
expect(runGroup(['create', 'complete']).status).toBe(0);
seedRegistry('complete', { ...baseRegistry, unreadableRepos: [] });
const j = JSON.parse(runGroup(['contracts', 'complete', '--json']).stdout) as Record<
string,
unknown
>;
expect(j.truncated).toBe(false);
expect(j.truncationReason).toBeUndefined();
expect(j.riskEpistemic).toBeUndefined();
const h = runGroup(['contracts', 'complete']);
expect(h.stdout.toLowerCase()).not.toContain('incomplete');
});
});
/**
* The per-repo status table had ONE failure label — `MISSING (no entry in the
* registry)` — and every reason a repo failed to resolve was printed with it,
* including a global registry that could not be read at all. For that case the
* line states something nobody measured (the command never got to read any
* entry) and points at the wrong repair: index the repo, when the fix is to
* repair the registry.
*
* These cases go through the real CLI because the label is the deliverable —
* the service payload can carry the distinction perfectly while the table
* still prints one word for both.
*/
describe('group status names which failure a repo hit', () => {
let home: string;
/** Two members: one the registry will know about, one it never will. */
const GROUP_YAML = `version: 1
name: labels
description: ""
repos:
backend: backend-registry
svc/users: svc-users-registry
links: []
packages: {}
detect:
http: false
grpc: false
thrift: false
topics: false
shared_libs: false
embedding_fallback: false
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`;
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-status-labels-'));
const groupDir = path.join(home, 'groups', 'labels');
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(path.join(groupDir, 'group.yaml'), GROUP_YAML, 'utf8');
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
});
/**
* A registry row that survives `LocalBackend.init()`'s validation pass —
* which prunes (and rewrites) any entry whose storage path has no metadata
* file, so a row backed by nothing would silently become a genuine absence
* before `group status` ever read the registry.
*/
const registeredRow = (name: string, dirName: string): Record<string, string> => {
const repoPath = path.join(home, dirName);
const storagePath = path.join(repoPath, '.gitnexus');
fs.mkdirSync(storagePath, { recursive: true });
fs.writeFileSync(path.join(storagePath, INDEX_METADATA_FILE), '{}', 'utf8');
return {
name,
path: repoPath,
storagePath,
indexedAt: '2026-01-01T00:00:00.000Z',
lastCommit: 'abc123',
};
};
const writeRegistry = (body: string): void =>
fs.writeFileSync(path.join(home, 'registry.json'), body, 'utf8');
it('says MISSING for a repo a readable registry simply does not hold', () => {
// The label this command has always printed, kept honest: the registry
// reads fine and genuinely has no row for either member.
writeRegistry('[]');
const r = runGroupIn(home, ['status', 'labels']);
expect(r.status).toBe(0);
expect(r.stdout).toMatch(/^ +backend +MISSING {3}\(no entry in the registry\)$/m);
expect(r.stdout).toMatch(/^ +svc\/users +MISSING {3}\(no entry in the registry\)$/m);
expect(r.stdout).not.toContain('UNRESOLVABLE');
});
it('says UNRESOLVABLE for a repo the registry holds but cannot resolve', () => {
// Two registered clones under one name: the row is right there, and
// resolution still cannot pick one. Printing "no entry in the registry"
// here would be a false statement about the file just read — and the two
// members must come out with DIFFERENT labels in the same table.
writeRegistry(
JSON.stringify([
registeredRow('backend-registry', 'clone-a'),
registeredRow('backend-registry', 'clone-b'),
]),
);
const r = runGroupIn(home, ['status', 'labels']);
expect(r.status).toBe(0);
// One line, not four: the ambiguity error is multi-line and gets folded.
expect(r.stdout).toMatch(/^ +backend +UNRESOLVABLE \(.*backend-registry.*\)$/m);
expect(r.stdout).toMatch(/^ +svc\/users +MISSING {3}\(no entry in the registry\)$/m);
});
it('says UNRESOLVABLE for every member when the registry itself cannot be read', () => {
// Nothing was measured about any repo, so "no entry in the registry" is a
// claim about a file that could not be parsed. Every configured member is
// unresolved — including one whose row might have been perfectly fine.
writeRegistry('{"repos": []}');
const r = runGroupIn(home, ['status', 'labels']);
expect(r.status).toBe(0);
expect(r.stdout).toMatch(/^ +backend +UNRESOLVABLE \(.*registry\.json.*\)$/m);
expect(r.stdout).toMatch(/^ +svc\/users +UNRESOLVABLE \(.*registry\.json.*\)$/m);
expect(r.stdout).not.toContain('MISSING');
});
});
/**
* A group.yaml with every detector off, so nothing in a sync opens a repo graph
* and the only thing that can vary is what the registry says about its members.
*
* `links` is spliced in verbatim because the two shapes below need different
* ones: a manifest link is the single input that makes a sync produce contracts
* with no indexed repo (synthetic UIDs — see
* `group-service-sync-lazy-import.test.ts`), which is what gives the wrote-line
* counts to assert something other than zeroes.
*/
function writeGroupYaml(
home: string,
group: string,
repos: Record<string, string>,
links = '[]',
): string {
const groupDir = path.join(home, 'groups', group);
fs.mkdirSync(groupDir, { recursive: true });
const repoLines = Object.entries(repos)
.map(([groupPath, registryName]) => ` ${groupPath}: ${registryName}`)
.join('\n');
fs.writeFileSync(
path.join(groupDir, 'group.yaml'),
`version: 1
name: ${group}
description: ""
repos:
${repoLines}
links: ${links}
packages: {}
detect:
http: false
grpc: false
thrift: false
topics: false
shared_libs: false
embedding_fallback: false
includes: false
workspace_deps: false
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`,
'utf8',
);
return groupDir;
}
/**
* `group sync` has three mutually exclusive things it can say about
* contracts.json, and the sentence is the ONLY channel that distinguishes them:
* all three exit 0, and two of them leave the file's contract counts identical.
*
* The line used to be the unconditional `Wrote contracts.json (0 contracts, 0
* cross-links)`, printed even on a run that deliberately kept the previous
* registry — a confident false statement about persisted state on the exact
* path this command exists to make legible. These go through the real CLI
* because the sentence IS the deliverable: the service payload can carry
* `registryOutcome` perfectly while the console still says one thing for all
* three.
*/
describe('group sync says what it did to contracts.json', () => {
let home: string;
/** Contracts a preserve run must carry forward untouched. */
const PRIOR_REGISTRY = {
version: 1,
generatedAt: '2026-01-01T00:00:00.000Z',
repoSnapshots: {},
missingRepos: [],
unreadableRepos: [],
contracts: [],
crossLinks: [],
};
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-sync-outcome-'));
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
});
/**
* Registry rows whose storage directory exists but holds no `lbug` file, so
* `initLbug` throws `LadybugDB not found at …` for every one of them. That is
* a load ERROR, not an absence: the repos resolve, and every one of them
* lands on `unreadableRepos` — the only state that reaches the two
* total-failure branches. A row missing from registry.json instead reports as
* MISSING and syncs to a written registry, which is the other case below.
*/
const registerReposWithNoIndex = (registryNames: Record<string, string>): void => {
const rows = Object.entries(registryNames).map(([registryName, dirName]) => {
const repoPath = path.join(home, dirName);
const storagePath = path.join(repoPath, '.gitnexus');
fs.mkdirSync(storagePath, { recursive: true });
return {
name: registryName,
path: repoPath,
storagePath,
indexedAt: '2026-01-01T00:00:00.000Z',
lastCommit: 'abc123',
};
});
fs.writeFileSync(path.join(home, 'registry.json'), JSON.stringify(rows), 'utf8');
};
it('prints what it wrote, and the counts, on a sync that produced a registry', () => {
// Every member is genuinely absent from the registry, which is a clean
// (if empty-handed) sync: the total-failure guard is gated on a load error,
// never on an empty result. The declared manifest link still yields two
// synthetic contracts and one cross-link, so the counts in the line are
// non-zero and therefore say something.
const groupDir = writeGroupYaml(
home,
'wrote',
{ 'app/backend': 'wrote-backend', 'app/frontend': 'wrote-frontend' },
`
- from: app/frontend
to: app/backend
type: custom
contract: rotateSigningKey
role: consumer`,
);
fs.writeFileSync(path.join(home, 'registry.json'), '[]', 'utf8');
const r = runGroupIn(home, ['sync', 'wrote']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('Wrote contracts.json (2 contracts, 1 cross-links)');
// The other two sentences are about the same file and contradict this one.
expect(r.stdout).not.toContain('Kept the previous contracts.json');
expect(r.stdout).not.toContain('Did NOT write contracts.json');
expect(fs.existsSync(path.join(groupDir, 'contracts.json'))).toBe(true);
});
it('says the previous contracts.json was KEPT when no repo could be read', () => {
// "Did NOT write contracts.json" was false here: this path REWRITES the
// file, keeping the previous sync's contracts and replacing only the two
// diagnostic lists. Saying otherwise sent an operator looking at an
// unchanged mtime to conclude the sync had not run.
const groupDir = writeGroupYaml(home, 'kept', {
'app/backend': 'kept-backend',
'app/frontend': 'kept-frontend',
});
registerReposWithNoIndex({ 'kept-backend': 'backend', 'kept-frontend': 'frontend' });
const contractsPath = path.join(groupDir, 'contracts.json');
fs.writeFileSync(contractsPath, JSON.stringify(PRIOR_REGISTRY), 'utf8');
const r = runGroupIn(home, ['sync', 'kept']);
expect(r.status).toBe(0);
expect(r.stdout).toContain(
'Kept the previous contracts.json — no repo in this group could be read.',
);
expect(r.stdout).toContain('Its contracts and cross-links are unchanged');
expect(r.stdout).not.toContain('Wrote contracts.json');
expect(r.stdout).not.toContain('Did NOT write contracts.json');
// What makes the sentence true rather than merely present: the file is
// still there, its contracts are the previous run's, and only the
// diagnostic list describes THIS run.
const onDisk = JSON.parse(fs.readFileSync(contractsPath, 'utf8')) as Record<string, unknown>;
expect(onDisk.contracts).toEqual(PRIOR_REGISTRY.contracts);
expect(onDisk.generatedAt).toBe(PRIOR_REGISTRY.generatedAt);
expect(onDisk.unreadableRepos).toEqual(['app/backend', 'app/frontend']);
});
it('says nothing was written when no repo could be read and there is no prior registry', () => {
// Distinct from the branch above on purpose: there is nothing on disk to
// keep, so promising the previous sync's contracts are safe would send an
// operator whose group has never synced looking for a file that has never
// existed.
const groupDir = writeGroupYaml(home, 'nothing', {
'app/backend': 'nothing-backend',
'app/frontend': 'nothing-frontend',
});
registerReposWithNoIndex({ 'nothing-backend': 'backend', 'nothing-frontend': 'frontend' });
const r = runGroupIn(home, ['sync', 'nothing']);
expect(r.status).toBe(0);
expect(r.stdout).toContain(
'Did NOT write contracts.json — no repo in this group could be read,',
);
expect(r.stdout).toContain('there is no previous contracts.json to fall back on');
expect(r.stdout).not.toContain('Wrote contracts.json');
expect(r.stdout).not.toContain('Kept the previous contracts.json');
// And the claim is true of disk: no file was invented to go with it.
expect(fs.existsSync(path.join(groupDir, 'contracts.json'))).toBe(false);
});
});
/**
* `undefined` and `[]` are different answers about the last sync's unreadable
* repos — "never recorded" versus the measurement "none" — and `group status`
* is where an operator reads them. Printing nothing for both would let an
* unmeasured sync read as evidence that every index opened cleanly, which is
* the fail-open the tri-state exists to close.
*/
describe('group status reports what the last sync recorded as unreadable', () => {
let home: string;
const BASE_REGISTRY = {
version: 1,
generatedAt: '2026-01-01T00:00:00.000Z',
repoSnapshots: {},
missingRepos: [],
contracts: [],
crossLinks: [],
};
const NOT_RECORDED_LINE = 'Last sync unreadable repos: not recorded';
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-status-unreadable-'));
// An empty registry, so every member reports MISSING and nothing in the
// per-repo table can vary between these three cases.
fs.writeFileSync(path.join(home, 'registry.json'), '[]', 'utf8');
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
});
const seed = (group: string, registry: Record<string, unknown>): void => {
const groupDir = writeGroupYaml(home, group, {
'app/backend': `${group}-backend`,
'app/frontend': `${group}-frontend`,
});
fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(registry), 'utf8');
};
it('says the field was not recorded when the registry never carried it', () => {
// A contracts.json written before the field existed has no opinion about
// which indexes were readable, and the remedy is to re-run the sync — not
// to conclude that none of them failed.
seed('unrecorded', BASE_REGISTRY);
const r = runGroupIn(home, ['status', 'unrecorded']);
expect(r.status).toBe(0);
expect(r.stdout).toContain(NOT_RECORDED_LINE);
expect(r.stdout).toContain('the registry predates this field, or its value could not be read');
expect(r.stdout).toContain('Re-run `gitnexus group sync` to record it.');
});
it('says nothing at all when the registry recorded an empty list', () => {
// `[]` is a measurement — this sync accounted for every repo — so there is
// no caveat to print and no repo to name. Reporting the "not recorded"
// caveat here would tell an operator to re-run the sync that just
// succeeded.
seed('measured', { ...BASE_REGISTRY, unreadableRepos: [] });
const r = runGroupIn(home, ['status', 'measured']);
expect(r.status).toBe(0);
expect(r.stdout).not.toContain('Last sync unreadable repos');
});
it('names the repos when the registry recorded some', () => {
// Without this, "says nothing at all" above would also be satisfied by a
// command that never printed this line on any registry.
seed('named', { ...BASE_REGISTRY, unreadableRepos: ['app/backend'] });
const r = runGroupIn(home, ['status', 'named']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('Last sync unreadable repos: app/backend');
expect(r.stdout).not.toContain(NOT_RECORDED_LINE);
});
});