Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/__tests__/dry-run-load-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ vi.mock('../utils/reports-branch.js', async (importOriginal) => ({
updateReports: vi.fn(),
}));

import { contribute } from '../contribute.js';
import { loadLocalConfigForScope } from '../config.js';
import { recall } from '../recall.js';
import { rolesSet } from '../roles-cmd.js';
import { tagsSubscribe, tagsUnsubscribe } from '../tags.js';
import { updateReports } from '../utils/reports-branch.js';
Expand Down Expand Up @@ -181,3 +184,56 @@ describe.each(FIXTURES)('--dry-run on %s', (_fixture, setup) => {
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('[dry-run] Would'));
});
});

describe('--dry-run through the loaders the commands share (#850)', () => {
const originalCwd = process.cwd();
const roots: string[] = [];

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
process.chdir(originalCwd);
for (const dir of roots.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});

function legacyRoot(): { root: string; configPath: string } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-dry-run-loader-'));
roots.push(root);
const home = path.join(root, 'home');
fs.mkdirSync(path.join(home, '.teamai'), { recursive: true });
vi.stubEnv('HOME', home);
process.chdir(setupLegacyRoleConfig(root));
return { root, configPath: path.join(home, '.teamai', 'config.yaml') };
}

it('recall --dry-run writes no file: the user scope loads through the flag (#850)', async () => {
const { root } = legacyRoot();
const before = snapshotTree(root);
await recall('dry run probe', { dryRun: true });
expect(snapshotTree(root)).toEqual(before);
});

it('contribute --scope user --dry-run writes no file on a config pending the role migration (#850)', async () => {
const { root } = legacyRoot();
// In the tree before the snapshot, so the run itself adds nothing.
const file = path.join(process.cwd(), 'note.md');
fs.writeFileSync(file, 'Learned: a dry run must not migrate the teamai config.\n');
const before = snapshotTree(root);
await contribute({ file, scope: 'user', dryRun: true });
expect(snapshotTree(root)).toEqual(before);
});

it('the loader previews the legacy role migration under --dry-run and writes nothing (#850)', async () => {
const { configPath } = legacyRoot();
const loaded = await loadLocalConfigForScope('user', undefined, { dryRun: true });
expect(loaded?.primaryRole).toBe('hai');
expect(fs.readFileSync(configPath, 'utf-8')).not.toContain('primaryRole');
});

it('the loader still migrates in place when the caller passes nothing, as before (#850)', async () => {
const { configPath } = legacyRoot();
const loaded = await loadLocalConfigForScope('user');
expect(loaded?.primaryRole).toBe('hai');
expect(fs.readFileSync(configPath, 'utf-8')).toContain('primaryRole: hai');
});
});
7 changes: 4 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,22 +229,23 @@ async function throwTeamConfigMissingOrInvalid(repoPath: string): Promise<never>
export async function loadLocalConfigForScope(
scope: Scope,
projectRoot?: string,
options: LoadOptions = {},
): Promise<LocalConfig | null> {
if (scope === 'project') {
if (!projectRoot) return null;
// Reuse the single detection path so config location never drifts between
// "detect the active project" and "load a named project's config".
const detected = await detectProjectConfig(projectRoot);
const detected = await detectProjectConfig(projectRoot, undefined, options);
if (!detected) return null;
return migrateLegacyRoleConfig(detected, path.join(getDataHome(detected), 'config.yaml'));
return migrateLegacyRoleConfig(detected, path.join(getDataHome(detected), 'config.yaml'), options);
}
const configPath = getConfigPath(scope, projectRoot);
const content = await readFileSafe(expandHome(configPath));
if (!content) return null;
try {
const raw = YAML.parse(content);
const parsed = LocalConfigSchema.parse(raw);
return await migrateLegacyRoleConfig(parsed, configPath);
return await migrateLegacyRoleConfig(parsed, configPath, options);
} catch (e) {
log.error(`Invalid ${scope} config at ${configPath}: ${describeConfigError(e)}`);
return null;
Expand Down
13 changes: 8 additions & 5 deletions src/contribute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,19 +191,22 @@ export async function contribute(
return;
}

// Init check — select scope based on --scope flag or auto-detect
// Init check — select scope based on --scope flag or auto-detect. The flag
// reaches the loaders: a bare load migrates the legacy role config in place,
// which would write under --dry-run (#850).
const loadOpts = { dryRun: options.dryRun };
let localConfig: LocalConfig;
if (options.scope === 'project') {
const cfg = await loadLocalConfigForScope('project', process.cwd());
const cfg = await loadLocalConfigForScope('project', process.cwd(), loadOpts);
if (!cfg) { log.error('No project-level teamai config in this directory'); return; }
localConfig = cfg;
} else if (options.scope === 'user') {
const { localConfig: userCfg } = await requireInit();
const { localConfig: userCfg } = await requireInit(loadOpts);
localConfig = userCfg;
} else {
// Auto-detect (unchanged default behavior)
const projectConfig = await detectProjectConfig();
localConfig = projectConfig ?? (await requireInit()).localConfig;
const projectConfig = await detectProjectConfig(undefined, undefined, loadOpts);
localConfig = projectConfig ?? (await requireInit(loadOpts)).localConfig;
}
assertNotReadOnly(localConfig, 'teamai contribute');
const username = localConfig.username;
Expand Down
8 changes: 5 additions & 3 deletions src/recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,9 @@ export async function recall(
// not reach that scope's team (#787).
let projectUnreadable = false;
try {
projectConfig = await detectProjectConfig(undefined, (configPath, error) => { unreadable.push(`${configPath}: ${error}`); });
// The flag reaches detection: a bare load migrates the legacy role config
// in place, which would write under --dry-run (#850).
projectConfig = await detectProjectConfig(undefined, (configPath, error) => { unreadable.push(`${configPath}: ${error}`); }, { dryRun: options.dryRun });
} catch (e) {
// A cwd that no longer exists holds no project: user scope, as in
// resolveConfigForDir.
Expand Down Expand Up @@ -510,7 +512,7 @@ export async function recall(

if (projectConfig.inheritUserScope === true) {
try {
const userConfig = await loadLocalConfigForScope('user');
const userConfig = await loadLocalConfigForScope('user', undefined, { dryRun: options.dryRun });
if (userConfig) {
const result = await loadOrBuildScopeIndex(userConfig, 'user');
if (result === 'build-failed') indexBuildFailed = true;
Expand All @@ -525,7 +527,7 @@ export async function recall(
} else {
// User mode: user scope only.
try {
const { localConfig: userConfig } = await requireInit();
const { localConfig: userConfig } = await requireInit({ dryRun: options.dryRun });
const result = await loadOrBuildScopeIndex(userConfig, 'user');
if (result === 'build-failed') indexBuildFailed = true;
else if (result && result.index.entries.length > 0) {
Expand Down
11 changes: 7 additions & 4 deletions src/save-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,20 +96,23 @@ export async function saveSession(options: SaveSessionOptions): Promise<void> {
return;
}

// The flag reaches the loaders: a bare load migrates the legacy role config
// in place, which would write under --dry-run (#850).
const loadOpts = { dryRun: options.dryRun };
let localConfig: LocalConfig;
try {
if (options.scope === 'project') {
const cfg = await loadLocalConfigForScope('project', process.cwd());
const cfg = await loadLocalConfigForScope('project', process.cwd(), loadOpts);
if (!cfg) {
log.error('No project-level teamai config in the current directory.');
return;
}
localConfig = cfg;
} else if (options.scope === 'user') {
localConfig = (await requireInit()).localConfig;
localConfig = (await requireInit(loadOpts)).localConfig;
} else {
const projectConfig = await detectProjectConfig();
localConfig = projectConfig ?? (await requireInit()).localConfig;
const projectConfig = await detectProjectConfig(undefined, undefined, loadOpts);
localConfig = projectConfig ?? (await requireInit(loadOpts)).localConfig;
}
} catch (e) {
log.error(`Cannot push: ${(e as Error).message}`);
Expand Down
Loading