diff --git a/src/__tests__/dry-run-load-path.test.ts b/src/__tests__/dry-run-load-path.test.ts index 1d1271020..715fc4d21 100644 --- a/src/__tests__/dry-run-load-path.test.ts +++ b/src/__tests__/dry-run-load-path.test.ts @@ -527,4 +527,32 @@ describe('--dry-run on a fresh self-mode clone (#866)', () => { // A dry run may parse the remote, but nothing else may reach a provider. expect(providerCalls).toEqual([]); }); + + // `pull` also refreshes the team repo, and in self mode that refresh self-heals + // an older `.teamai/.gitignore` — a TRACKED file in the member's own checkout. + // The fixture above has no `.teamai/.gitignore` at all, and the migration + // returns early when the file is missing, so this was the one path in the + // refresh the assertion above could not reach (#866 review). + it('pull --dry-run does not rewrite an existing .teamai/.gitignore (#866)', async () => { + const project = process.cwd(); + const gitignore = path.join(project, '.teamai', '.gitignore'); + // A pre-beta.5 shape: a bare `env` line, the one the self-heal drops so team + // env vars can reach main. Present and committed, as it would be for a + // teammate who cloned before the migration landed. + const preBeta5 = 'token\nenv\nenv.local\n'; + fs.writeFileSync(gitignore, preBeta5); + const git = (...args: string[]) => + execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd: project, stdio: 'ignore' }); + git('add', '-A'); + git('commit', '-q', '-m', 'pre-beta.5 gitignore'); + + const before = snapshotTree(root); + const error = await pull({ dryRun: true }).then(() => null, (e: unknown) => e); + + expect(error).toBeNull(); + expect(fs.readFileSync(gitignore, 'utf-8')).toBe(preBeta5); + const after = snapshotTree(root); + for (const key of Object.keys(before)) expect(after[key]).toBe(before[key]); + expect(providerCalls).toEqual([]); + }); }); diff --git a/src/pull.ts b/src/pull.ts index 509a8135c..7559d8d39 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -83,9 +83,14 @@ const FILE_NOT_FOUND_ERROR_CODE = 'ENOENT'; * `submodulesChanged` marks a run whose submodule update succeeded but moved the * tree on disk: the caller must then NOT take that same fast path *this* run, * because the parent rev alone cannot see the change (issue #525). + * + * `options.dryRun` marks a preview. The refresh still reads the tree, but the + * self-mode `.gitignore` self-heal is skipped: it rewrites a tracked file in the + * member's checkout, and a preview writes nothing (#866). */ async function refreshTeamRepo( localConfig: LocalConfig, + options: { dryRun?: boolean } = {}, ): Promise<{ label: string; version: string | null; submodulesFailed: boolean; submodulesChanged: boolean }> { if (localConfig.repo.kind === 'http') { const { resolveApiKey } = await import('./api-key.js'); @@ -108,10 +113,15 @@ async function refreshTeamRepo( // Self-heal an older .teamai/.gitignore that still ignores `env` (pre-beta.5), // which would keep team env vars off main. Best-effort; prompts the user to // commit the change. - try { - const { migrateSelfModeGitignore } = await import('./init.js'); - await migrateSelfModeGitignore(localConfig); - } catch { /* best-effort */ } + // + // It rewrites a TRACKED file, so a preview must not run it. The migration is + // idempotent, so the next real pull performs it (#866). + if (!options.dryRun) { + try { + const { migrateSelfModeGitignore } = await import('./init.js'); + await migrateSelfModeGitignore(localConfig); + } catch { /* best-effort */ } + } let version: string | null = null; try { @@ -913,7 +923,7 @@ async function pullForScope( // unchanged-rev fast path for THIS run — the parent rev cannot see it (#525). let submodulesChanged = false; try { - const refresh = await refreshTeamRepo(localConfig); + const refresh = await refreshTeamRepo(localConfig, options); currentRev = refresh.version; submodulesFailed = refresh.submodulesFailed; submodulesChanged = refresh.submodulesChanged;