From f40b7b374846ed6522743c47d4f8b58fa3cad01c Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Sat, 3 Oct 2026 17:03:45 +0200 Subject: [PATCH 1/8] fix(dry-run): preview roles and projects edits without pulling (#900) roles add/remove/update and projects add/update/remove pulled the team repo before their dry-run guard, and a pull that cannot fast-forward resets the clone to origin, discarding unpushed commits. In single-repo mode they also created the knowledge worktree. runManifestEdit now takes { dryRun }: a preview fetches and reads origin/ from a --shared clone in the OS temp dir (withDefaultBranchPreview), so the member's repo only sees a fetch. roles init skips its pull on a dry run. Refs #900 (C5) --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- .../e2e/dry-run-git-previews-900.test.ts | 319 ++++++++++++++++++ src/__tests__/manifest-edit.test.ts | 27 +- src/__tests__/projects-cmd.test.ts | 18 +- src/manifest-edit.ts | 35 +- src/projects-cmd.ts | 6 +- src/roles-cmd.ts | 17 +- src/utils/reports-branch.ts | 56 ++- 9 files changed, 437 insertions(+), 45 deletions(-) create mode 100644 src/__tests__/e2e/dry-run-git-previews-900.test.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index d16b5ba08..9a608ce0c 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -936,7 +936,7 @@ teamai roles remove devops teamai roles add test --namespaces common,test --dry-run ``` -The `--namespaces` list is applied to `knowledge`, `skills` and `agents` alike. The commands above automatically push a branch and create an MR; the change takes effect team-wide once merged. +The `--namespaces` list is applied to `knowledge`, `skills` and `agents` alike. The commands above automatically push a branch and create an MR; the change takes effect team-wide once merged. With `--dry-run`, `teamai roles add/update/remove` and `teamai projects add/update/remove` fetch and read the manifest on origin's default branch; they do not pull the team repo or, in single-repo mode, create a worktree, so commits you have not pushed stay. **Member operations:** diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 498229d9e..31173ab1c 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -837,7 +837,7 @@ teamai roles remove devops teamai roles add test --namespaces common,test --dry-run ``` -`--namespaces` 列表会同时应用到 `knowledge`、`skills` 与 `agents`。以上命令会自动 push 分支并创建 MR,合并后对全团队生效。 +`--namespaces` 列表会同时应用到 `knowledge`、`skills` 与 `agents`。以上命令会自动 push 分支并创建 MR,合并后对全团队生效。加 `--dry-run` 时,`teamai roles add/update/remove` 与 `teamai projects add/update/remove` 只 fetch 并读取 origin 默认分支上的 manifest,不会 pull 团队仓库,单仓模式下也不会创建 worktree,因此尚未推送的提交会保留。 **成员操作:** diff --git a/src/__tests__/e2e/dry-run-git-previews-900.test.ts b/src/__tests__/e2e/dry-run-git-previews-900.test.ts new file mode 100644 index 000000000..1bdd99963 --- /dev/null +++ b/src/__tests__/e2e/dry-run-git-previews-900.test.ts @@ -0,0 +1,319 @@ +/** + * E2E (#900 C5): a dry run of `roles add/remove/update` and + * `projects add/update/remove` leaves the team clone exactly as it was. + * + * These commands used to pull before their dry-run guard, and `pullRepo` falls + * back to `git reset --hard origin/` when a fast-forward fails, so a dry run + * on a clone with unpushed commits discarded them. `remove` also saved the + * reconciled placement records, and in single-repo mode both commands created + * the knowledge worktree. A preview may fetch; it must not move HEAD, touch the + * working tree, save state or add a worktree. It still resolves names against + * the default branch on origin, as the real run does. + */ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..', '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +const GIT_ENV = { + GIT_AUTHOR_NAME: 'TeamAI CI', + GIT_AUTHOR_EMAIL: 'ci@teamai.test', + GIT_COMMITTER_NAME: 'TeamAI CI', + GIT_COMMITTER_EMAIL: 'ci@teamai.test', +}; + +interface RunResult { + code: number | null; + output: string; +} + +function runCLI(args: string[], cwd: string, home: string): Promise { + return new Promise((resolve) => { + const child = spawn('node', [CLI, ...args], { + cwd, + env: { ...process.env, ...GIT_ENV, HOME: home, USERPROFILE: home, FORCE_COLOR: '0' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout.on('data', (data: Buffer) => { output += data.toString(); }); + child.stderr.on('data', (data: Buffer) => { output += data.toString(); }); + child.on('close', (code) => resolve({ code, output })); + }); +} + +function git(args: string[], cwd: string): string { + return execFileSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, ...GIT_ENV } }); +} + +const ROLES_YAML = [ + 'version: 1', + 'roles:', + ' - id: base', + ' description: ""', + ' resources:', + ' knowledge: [base]', + ' skills: [base]', + ' agents: [base]', + '', +].join('\n'); + +const PROJECTS_YAML = [ + 'version: 1', + 'projects:', + ' - id: alpha', + ' name: ""', + ' description: ""', + ' resources:', + ' knowledge: [alpha]', + ' skills: [alpha]', + ' agents: [alpha]', + '', +].join('\n'); + +function writeTeamFiles(dir: string, extra: string[]): void { + fs.mkdirSync(path.join(dir, 'manifest'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'rules'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'teamai.yaml'), [ + 'team: issue-900-e2e', + ...extra, + 'repo: https://git.example.com/team/repo.git', + 'provider: git', + 'usageReport: false', + 'toolPaths:', + ' claude:', + ' rules: .claude/rules', + '', + ].join('\n')); + fs.writeFileSync(path.join(dir, 'manifest', 'roles.yaml'), ROLES_YAML); + fs.writeFileSync(path.join(dir, 'manifest', 'projects.yaml'), PROJECTS_YAML); + fs.writeFileSync(path.join(dir, 'rules', 'doomed.md'), '# Doomed\n'); +} + +/** + * Origin moves on after the member's last pull: it gains a role and a rule + * the member's checkout has not seen. A real run would pull them first. + */ +function advanceOrigin(upstream: string, knowledgeRel: string): void { + const knowledge = path.join(upstream, knowledgeRel); + fs.appendFileSync(path.join(knowledge, 'manifest', 'roles.yaml'), [ + ' - id: remote-role', + ' description: ""', + ' resources:', + ' knowledge: [remote]', + ' skills: [remote]', + ' agents: [remote]', + '', + ].join('\n')); + fs.writeFileSync(path.join(knowledge, 'rules', 'fresh.md'), '# Fresh\n'); + git(['add', '-A'], upstream); + git(['commit', '-q', '-m', 'upstream moves on'], upstream); + git(['push', '-q', 'origin', 'main'], upstream); +} + +/** The member's checkout diverges: a commit origin does not have. */ +function commitLocally(checkout: string): void { + fs.writeFileSync(path.join(checkout, 'local-note.md'), 'not pushed yet\n'); + git(['add', 'local-note.md'], checkout); + git(['commit', '-q', '-m', 'local work'], checkout); +} + +/** + * `git worktree add` runs the checkout's post-checkout hook, so a worktree + * created and removed within one run still leaves this mark. + */ +function markCheckouts(checkout: string): string { + const marker = path.join(path.dirname(checkout), `${path.basename(checkout)}.checkouts`); + const hook = path.join(checkout, '.git', 'hooks', 'post-checkout'); + fs.writeFileSync(hook, `#!/bin/sh\necho "$PWD" >> "${marker}"\n`, { mode: 0o755 }); + return marker; +} + +/** + * A placement record whose file is not on the default branch: reconciling + * drops it, which a real `remove` saves before resolving names. + */ +const STATE_JSON = `${JSON.stringify({ placedRules: { gone: 'rules/ns/gone.md' } }, null, 2)}\n`; + +interface Snapshot { + head: string; + branch: string; + status: string; + worktrees: string; + state: string | null; + checkouts: string | null; +} + +function snapshot(fixture: Fixture): Snapshot { + const { checkout, statePath, checkoutMarker } = fixture; + return { + head: git(['rev-parse', 'HEAD'], checkout).trim(), + branch: git(['rev-parse', '--abbrev-ref', 'HEAD'], checkout).trim(), + status: git(['status', '--porcelain', '--untracked-files=all'], checkout), + worktrees: git(['worktree', 'list', '--porcelain'], checkout), + state: fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf8') : null, + checkouts: fs.existsSync(checkoutMarker) ? fs.readFileSync(checkoutMarker, 'utf8') : null, + }; +} + +interface Fixture { + home: string; + cwd: string; + checkout: string; + statePath: string; + checkoutMarker: string; +} + +const sandboxes: string[] = []; + +function newSandbox(): string { + const sandbox = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-900-git-previews-'))); + sandboxes.push(sandbox); + return sandbox; +} + +/** A dedicated team-repo clone (user scope), diverged from origin. */ +function setUpClone(): Fixture { + const sandbox = newSandbox(); + const home = path.join(sandbox, 'home'); + const remote = path.join(sandbox, 'remote.git'); + const upstream = path.join(sandbox, 'upstream'); + const checkout = path.join(home, '.teamai', 'team-repo'); + + git(['init', '-q', '--bare', '-b', 'main', remote], sandbox); + fs.mkdirSync(upstream, { recursive: true }); + writeTeamFiles(upstream, []); + git(['init', '-q', '-b', 'main'], upstream); + git(['add', '-A'], upstream); + git(['commit', '-q', '-m', 'seed'], upstream); + git(['remote', 'add', 'origin', remote], upstream); + git(['push', '-q', '-u', 'origin', 'main'], upstream); + + fs.mkdirSync(path.dirname(checkout), { recursive: true }); + git(['clone', '-q', '-b', 'main', remote, checkout], sandbox); + fs.mkdirSync(path.join(home, '.claude', 'rules'), { recursive: true }); + fs.writeFileSync(path.join(home, '.teamai', 'config.yaml'), [ + 'repo:', + ` localPath: ${checkout}`, + ' remote: https://git.example.com/team/repo.git', + 'username: e2e', + 'updatePolicy: skip', + 'scope: user', + '', + ].join('\n')); + const statePath = path.join(home, '.teamai', 'state.json'); + fs.writeFileSync(statePath, STATE_JSON); + + advanceOrigin(upstream, '.'); + commitLocally(checkout); + return { home, cwd: home, checkout, statePath, checkoutMarker: markCheckouts(checkout) }; +} + +/** A single-repo (self mode) business checkout, diverged from origin. */ +function setUpSelf(): Fixture { + const sandbox = newSandbox(); + const home = path.join(sandbox, 'home'); + const remote = path.join(sandbox, 'origin.git'); + const upstream = path.join(sandbox, 'upstream'); + const checkout = path.join(sandbox, 'project'); + fs.mkdirSync(home, { recursive: true }); + + git(['init', '-q', '--bare', '-b', 'main', remote], sandbox); + const seed = path.join(upstream, '.teamai'); + fs.mkdirSync(seed, { recursive: true }); + writeTeamFiles(seed, ['mode: self']); + fs.writeFileSync(path.join(seed, '.gitignore'), 'config.yaml\nstate.json\nknowledge-wt/\n'); + fs.writeFileSync(path.join(upstream, 'app.txt'), 'business code\n'); + git(['init', '-q', '-b', 'main'], upstream); + git(['add', '-A'], upstream); + git(['commit', '-q', '-m', 'project'], upstream); + git(['remote', 'add', 'origin', remote], upstream); + git(['push', '-q', '-u', 'origin', 'main'], upstream); + + git(['clone', '-q', '-b', 'main', remote, checkout], sandbox); + const knowledge = path.join(checkout, '.teamai'); + fs.writeFileSync(path.join(knowledge, 'config.yaml'), [ + 'repo:', + ` localPath: ${knowledge}`, + ` remote: ${remote}`, + ' kind: self', + ` businessRepoRoot: ${checkout}`, + 'username: e2e', + 'updatePolicy: skip', + 'scope: project', + `projectRoot: ${checkout}`, + '', + ].join('\n')); + const statePath = path.join(knowledge, 'state.json'); + fs.writeFileSync(statePath, STATE_JSON); + + advanceOrigin(upstream, '.teamai'); + commitLocally(checkout); + return { home, cwd: checkout, checkout, statePath, checkoutMarker: markCheckouts(checkout) }; +} + +async function dryRun(fixture: Fixture, args: string[]): Promise { + const before = snapshot(fixture); + const result = await runCLI([...args, '--dry-run'], fixture.cwd, fixture.home); + expect(snapshot(fixture), result.output).toEqual(before); + return result; +} + +beforeAll(() => { + if (!fs.existsSync(CLI)) { + throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + } +}); + +afterEach(() => { + for (const sandbox of sandboxes.splice(0)) fs.rmSync(sandbox, { recursive: true, force: true }); +}); + +describe.each([ + ['a team-repo clone', setUpClone], + ['single-repo mode', setUpSelf], +])('--dry-run on %s with unpushed commits (#900)', (_label, setUp) => { + it('roles add keeps the local commits and previews against origin', async () => { + const fixture = setUp(); + + const added = await dryRun(fixture, ['roles', 'add', 'x', '--namespaces', 'x']); + expect(added.code, added.output).toBe(0); + expect(added.output).toContain('[dry-run] Would add role "x" with namespaces: x'); + + // remote-role exists only on origin: the preview read origin, not the stale checkout. + const taken = await dryRun(fixture, ['roles', 'add', 'remote-role', '--namespaces', 'y']); + expect(taken.output).toContain('Role "remote-role" already exists'); + }); + + it('roles update and remove preview against origin without writing', async () => { + const fixture = setUp(); + + const updated = await dryRun(fixture, ['roles', 'update', 'remote-role', '--add-namespaces', 'z']); + expect(updated.code, updated.output).toBe(0); + expect(updated.output).toContain('[dry-run] Would update role "remote-role"'); + + const removed = await dryRun(fixture, ['roles', 'remove', 'remote-role']); + expect(removed.code, removed.output).toBe(0); + expect(removed.output).toContain('[dry-run] Would remove role "remote-role". Remaining: base'); + }); + + it('projects add, update and remove preview without writing', async () => { + const fixture = setUp(); + + const added = await dryRun(fixture, ['projects', 'add', 'beta', '--namespaces', 'beta']); + expect(added.code, added.output).toBe(0); + expect(added.output).toContain('[dry-run] Would add project "beta"'); + + const updated = await dryRun(fixture, ['projects', 'update', 'alpha', '--add-namespaces', 'more']); + expect(updated.output).toContain('[dry-run] Would update project "alpha"'); + + const removed = await dryRun(fixture, ['projects', 'remove', 'alpha']); + expect(removed.output).toContain('[dry-run] Would remove project "alpha"'); + }); + +}); diff --git a/src/__tests__/manifest-edit.test.ts b/src/__tests__/manifest-edit.test.ts index 53ae8663d..f0559d5e0 100644 --- a/src/__tests__/manifest-edit.test.ts +++ b/src/__tests__/manifest-edit.test.ts @@ -2,12 +2,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const reportsMocks = vi.hoisted(() => { class EmptyRepoError extends Error {} - return { withKnowledgeWorktree: vi.fn(), EmptyRepoError }; + return { withKnowledgeWorktree: vi.fn(), withDefaultBranchPreview: vi.fn(), EmptyRepoError }; }); vi.mock('../utils/reports-branch.js', () => reportsMocks); +const gitMocks = vi.hoisted(() => ({ pullRepo: vi.fn().mockResolvedValue('already up to date') })); +vi.mock('../utils/git.js', () => gitMocks); + const logMocks = vi.hoisted(() => ({ info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() })); -vi.mock('../utils/logger.js', () => ({ log: logMocks, spinner: vi.fn() })); +const spin = vi.hoisted(() => ({ succeed: vi.fn(), warn: vi.fn() })); +vi.mock('../utils/logger.js', () => ({ log: logMocks, spinner: vi.fn(() => ({ start: () => spin })) })); import { runManifestEdit } from '../manifest-edit.js'; import type { LocalConfig } from '../types.js'; @@ -25,16 +29,33 @@ function config(kind: 'self' | undefined, localPath: string): LocalConfig { describe('runManifestEdit', () => { beforeEach(() => vi.clearAllMocks()); - it('edits the team repo clone directly outside single-repo mode', async () => { + it('pulls and edits the team repo clone directly outside single-repo mode', async () => { const fn = vi.fn().mockResolvedValue(undefined); const localConfig = config(undefined, '/team-repo'); await runManifestEdit(localConfig, 'Projects', fn); + expect(gitMocks.pullRepo).toHaveBeenCalledWith('/team-repo'); expect(fn).toHaveBeenCalledWith('/team-repo', localConfig); expect(reportsMocks.withKnowledgeWorktree).not.toHaveBeenCalled(); }); + // The pull can reset a clone with unpushed commits, and the worktree is a + // write to the member's repo (#900). + it.each([undefined, 'self'] as const)('previews from origin without pulling or a worktree (kind %s)', async (kind) => { + const previewConfig = config(kind, '/tmp/preview'); + reportsMocks.withDefaultBranchPreview.mockImplementation( + async (_config: LocalConfig, body: (preview: LocalConfig) => Promise) => body(previewConfig), + ); + const fn = vi.fn().mockResolvedValue(undefined); + + await runManifestEdit(config(kind, '/team-repo'), 'Roles', fn, { dryRun: true }); + + expect(fn).toHaveBeenCalledWith('/tmp/preview', previewConfig); + expect(gitMocks.pullRepo).not.toHaveBeenCalled(); + expect(reportsMocks.withKnowledgeWorktree).not.toHaveBeenCalled(); + }); + it('edits inside the knowledge worktree in single-repo mode', async () => { const worktreeConfig = config('self', '/worktree/.teamai'); reportsMocks.withKnowledgeWorktree.mockImplementation( diff --git a/src/__tests__/projects-cmd.test.ts b/src/__tests__/projects-cmd.test.ts index b7eb521b0..b04618936 100644 --- a/src/__tests__/projects-cmd.test.ts +++ b/src/__tests__/projects-cmd.test.ts @@ -9,17 +9,17 @@ vi.mock('../config.js', () => ({ })); const editMocks = vi.hoisted(() => ({ - pullLatest: vi.fn().mockResolvedValue(undefined), pushManifestChange: vi.fn().mockResolvedValue(undefined), -})); -vi.mock('../manifest-edit.js', () => ({ - pullLatest: editMocks.pullLatest, - pushManifestChange: editMocks.pushManifestChange, - runManifestEdit: async ( + runManifestEdit: vi.fn(async ( localConfig: { repo: { localPath: string } }, _label: string, fn: (repoPath: string, editConfig: unknown) => Promise, - ) => fn(localConfig.repo.localPath, localConfig), + _options?: { dryRun?: boolean }, + ) => fn(localConfig.repo.localPath, localConfig)), +})); +vi.mock('../manifest-edit.js', () => ({ + pushManifestChange: editMocks.pushManifestChange, + runManifestEdit: editMocks.runManifestEdit, })); const logMocks = vi.hoisted(() => ({ @@ -91,7 +91,9 @@ describe('projects add / update / remove (#756)', () => { }, }], }); - expect(editMocks.pullLatest).toHaveBeenCalledWith(repoDir); + expect(editMocks.runManifestEdit).toHaveBeenCalledWith( + expect.anything(), 'Projects', expect.any(Function), { dryRun: undefined }, + ); expect(editMocks.pushManifestChange).toHaveBeenCalledWith(expect.objectContaining({ repoPath: repoDir, commitMsg: '[teamai] Add project "checkout"', diff --git a/src/manifest-edit.ts b/src/manifest-edit.ts index ee1b8208d..a95ee20c2 100644 --- a/src/manifest-edit.ts +++ b/src/manifest-edit.ts @@ -19,29 +19,34 @@ export async function pullLatest(repoPath: string): Promise { /** * Run a manifest admin edit (write manifest + open PR) against the right repo. * In single-repo mode the manifest is knowledge on main, so the edit runs inside - * an isolated knowledge worktree (never the user's active tree). `fn` receives - * the repoPath to read/write the manifest and the localConfig to use for the PR - * — both already scoped to the worktree in self mode. + * an isolated knowledge worktree (never the user's active tree); otherwise the + * team repo clone is pulled first. A dry run reads origin/ from a + * throwaway checkout instead and never pulls, since the pull can reset a clone + * with unpushed commits (#900). `fn` receives the repoPath to read/write the + * manifest and the localConfig to use for the PR, both scoped to that checkout. */ export async function runManifestEdit( localConfig: LocalConfig, label: string, fn: (repoPath: string, editConfig: LocalConfig) => Promise, + options: { dryRun?: boolean } = {}, ): Promise { - if (localConfig.repo.kind === 'self') { - const { withKnowledgeWorktree, EmptyRepoError } = await import('./utils/reports-branch.js'); - try { - await withKnowledgeWorktree(localConfig, (wtConfig) => fn(wtConfig.repo.localPath, wtConfig)); - } catch (e) { - if (e instanceof EmptyRepoError) { - log.error(e.message); - } else { - log.error(`${label} update failed: ${(e as Error).message}`); - } - } + if (!options.dryRun && localConfig.repo.kind !== 'self') { + await pullLatest(localConfig.repo.localPath); + await fn(localConfig.repo.localPath, localConfig); return; } - await fn(localConfig.repo.localPath, localConfig); + const { withKnowledgeWorktree, withDefaultBranchPreview, EmptyRepoError } = await import('./utils/reports-branch.js'); + const withCheckout = options.dryRun ? withDefaultBranchPreview : withKnowledgeWorktree; + try { + await withCheckout(localConfig, (checkoutConfig) => fn(checkoutConfig.repo.localPath, checkoutConfig)); + } catch (e) { + if (e instanceof EmptyRepoError) { + log.error(e.message); + } else { + log.error(`${label} update failed: ${(e as Error).message}`); + } + } } export async function pushManifestChange(input: { diff --git a/src/projects-cmd.ts b/src/projects-cmd.ts index 1bc05055b..b5b1776bc 100644 --- a/src/projects-cmd.ts +++ b/src/projects-cmd.ts @@ -17,7 +17,7 @@ import { PROJECT_RESOURCE_TYPES, } from './projects.js'; import type { ProjectsManifest, TeamProject } from './projects.js'; -import { pullLatest, runManifestEdit, pushManifestChange } from './manifest-edit.js'; +import { runManifestEdit, pushManifestChange } from './manifest-edit.js'; import { readFileSafe, listFiles } from './utils/fs.js'; import { pullRepo } from './utils/git.js'; import { log } from './utils/logger.js'; @@ -251,8 +251,6 @@ async function editProjectsManifest( const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); await runManifestEdit(localConfig, 'Projects', async (repoPath, editConfig) => { - if (editConfig.repo.kind !== 'self') await pullLatest(repoPath); - let current: ProjectsManifest | null; try { current = await loadProjectsManifest(repoPath); @@ -288,7 +286,7 @@ async function editProjectsManifest( commitMsg: result.commitMsg, prDescription: done, }); - }); + }, { dryRun: options.dryRun }); } export async function projectsAdd( diff --git a/src/roles-cmd.ts b/src/roles-cmd.ts index f63034567..8c243ba88 100644 --- a/src/roles-cmd.ts +++ b/src/roles-cmd.ts @@ -29,8 +29,9 @@ export async function rolesInit(options: GlobalOptions): Promise { // In self mode the manifest is knowledge on main; the on-disk .teamai already // reflects main, so we read the existence check from there and only run the // actual write+PR inside an isolated worktree (below). Non-self modes pull the - // team repo clone first. - if (!selfMode) await pullLatest(repoPath); + // team repo clone first, except on a dry run: the pull can reset a clone with + // unpushed commits (#900), so the preview checks the clone as it is. + if (!selfMode && !options.dryRun) await pullLatest(repoPath); // Check if manifest already exists const manifestPath = path.join(repoPath, 'manifest', 'roles.yaml'); @@ -261,8 +262,6 @@ export async function rolesAdd( const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); await runManifestEdit(localConfig, 'Roles', async (repoPath, editConfig) => { - if (editConfig.repo.kind !== 'self') await pullLatest(repoPath); - let manifest: RolesManifest; try { manifest = await loadRolesManifest(repoPath); @@ -309,7 +308,7 @@ export async function rolesAdd( commitMsg, prDescription: `Add role "${roleId}" with namespaces: ${namespaces.join(', ')}${options.description ? `\nDescription: ${options.description}` : ''}`, }); - }); + }, { dryRun: options.dryRun }); } // ─── roles remove ─────────────────────────────────────── @@ -321,8 +320,6 @@ export async function rolesRemove( const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); await runManifestEdit(localConfig, 'Roles', async (repoPath, editConfig) => { - if (editConfig.repo.kind !== 'self') await pullLatest(repoPath); - let manifest: RolesManifest; try { manifest = await loadRolesManifest(repoPath); @@ -365,7 +362,7 @@ export async function rolesRemove( commitMsg, prDescription: `Remove role "${roleId}". Remaining roles: ${remaining.map((r) => r.id).join(', ')}`, }); - }); + }, { dryRun: options.dryRun }); } // ─── roles update ─────────────────────────────────────── @@ -390,8 +387,6 @@ export async function rolesUpdate( const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); await runManifestEdit(localConfig, 'Roles', async (repoPath, editConfig) => { - if (editConfig.repo.kind !== 'self') await pullLatest(repoPath); - let manifest: RolesManifest; try { manifest = await loadRolesManifest(repoPath); @@ -471,5 +466,5 @@ export async function rolesUpdate( commitMsg, prDescription: `Update role "${roleId}": ${changes.join(', ')}`, }); - }); + }, { dryRun: options.dryRun }); } diff --git a/src/utils/reports-branch.ts b/src/utils/reports-branch.ts index a6c1d201b..db0883fb7 100644 --- a/src/utils/reports-branch.ts +++ b/src/utils/reports-branch.ts @@ -5,12 +5,15 @@ * The branch machinery itself lives in `branch-worktree.ts`; reports are one * instance of it and learnings are the other. What stays here is the reports * spec, the names its callers already import, and the knowledge worktree, which - * is not a side branch at all: it is a throwaway checkout of the default branch. + * is not a side branch at all: it is a throwaway checkout of the default branch, + * as is the preview checkout a dry run reads instead. */ +import os from 'node:os'; import path from 'node:path'; import fse from 'fs-extra'; -import { createGit, getDefaultBranch, hasCommits } from './git.js'; +import { createGit, getDefaultBranch, getHeadCommit, hasCommits } from './git.js'; import { pathExists } from './fs.js'; +import { log } from './logger.js'; import { ForeignCheckoutError, createBranchWorktree, @@ -233,3 +236,52 @@ export async function withKnowledgeWorktree( } } } + +/** + * Run `fn` against a throwaway checkout of origin/: the tree a real + * `remove` or manifest edit reads after its pull (or, in single-repo mode, in + * its knowledge worktree), for a `--dry-run` that must not change anything. + * + * Only a fetch touches the member's repo. Pulling there can reset a clone + * that diverged from origin and discard its commits, and `withKnowledgeWorktree` + * adds a worktree (#900). The checkout is a `--shared` clone in the OS temp + * dir, so history reads work and nothing is written under the repo. `fn` + * receives a clone of localConfig pointed at it, laid out as the real run's. + */ +export async function withDefaultBranchPreview( + localConfig: LocalConfig, + fn: (previewConfig: LocalConfig) => Promise, +): Promise { + const selfMode = localConfig.repo.kind === 'self'; + const repoRoot = selfMode ? getBusinessRoot(localConfig) : localConfig.repo.localPath; + const git = createGit(repoRoot); + const defaultBranch = await getDefaultBranch(repoRoot); + try { + await git.fetch(['origin', defaultBranch]); + } catch (e) { + log.warn(`Could not fetch origin/${defaultBranch} (${(e as Error).message}); previewing against the copy fetched last.`); + } + // origin/ may not exist locally (fresh repo); fall back to HEAD, as the worktree does. + const base = await getHeadCommit(repoRoot, `origin/${defaultBranch}`) ?? await getHeadCommit(repoRoot); + if (!base) throw new EmptyRepoError(repoRoot); + + const dir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-preview-')); + try { + await git.raw(['clone', '--quiet', '--shared', '--no-checkout', repoRoot, dir]); + const preview = createGit(dir); + await preview.raw(['checkout', '--quiet', '--detach', base]); + // The clone's origin/* are the member's local branches; point the default + // branch at what origin has, as it is in the member's repo. + await preview.raw(['update-ref', `refs/remotes/origin/${defaultBranch}`, base]); + await preview.raw(['symbolic-ref', 'refs/remotes/origin/HEAD', `refs/remotes/origin/${defaultBranch}`]); + const previewConfig: LocalConfig = { + ...localConfig, + repo: selfMode + ? { ...localConfig.repo, localPath: path.join(dir, '.teamai'), businessRepoRoot: dir } + : { ...localConfig.repo, localPath: dir }, + }; + return await fn(previewConfig); + } finally { + await fse.remove(dir); + } +} From 055f2e041b68d7ba594d365235e1671f9993d5e5 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Sat, 3 Oct 2026 17:03:45 +0200 Subject: [PATCH 2/8] fix(dry-run): preview remove without pulling or saving state (#900) remove pulled the team repo and saved the reconciled placement records before its dry-run guard; in single-repo mode it ran inside the knowledge worktree. A dry run now resolves names in a temporary checkout of origin/, as the real run does after its pull, and hands the reconciled records to publishedNameFor in memory instead of saving them for it to read back. Refs #900 (C1) --- CHANGELOG.md | 1 + docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/agents.test.ts | 12 +++---- .../e2e/dry-run-git-previews-900.test.ts | 13 ++++++- src/__tests__/remove.test.ts | 6 ++-- src/remove.ts | 36 ++++++++++--------- src/resources/agents.ts | 6 ++-- src/resources/base.ts | 10 ++++-- src/resources/rules.ts | 6 ++-- 10 files changed, 55 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e34f6fbc2..c688ad33f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ All notable changes to this project will be documented in this file. See [standa - A user pull now repairs Hermes' `SOUL.md` rules block also when the team repo has not moved, so a block an older release's project pull overwrote is fixed by the first pull after upgrading; `doctor` says `teamai pull` for it (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - `teamai init` prints the Codex trust reminder when it installs hooks for the public Codex, and the reminder says that until the hooks are trusted a project's team rules do not reach Codex (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - When no team rule reaches a member any more, pull also removes a copy an older release wrote before the tool got its own rules format (a verbatim Kiro, Qoder, CodeBuddy, WorkBuddy or Oh My Pi copy, a Cursor render in JoyCode), not only one in the current format (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- `--dry-run` no longer discards commits you have not pushed. `teamai remove`, `teamai roles init/add/remove/update` and `teamai projects add/update/remove` pulled the team repo before their dry-run check, and a pull that cannot fast-forward resets the clone to origin; `remove` also saved this machine's placement records, and in single-repo mode both created the knowledge worktree. A dry run no longer pulls: `remove`, `roles add/remove/update` and `projects add/update/remove` fetch and read origin's default branch from a temporary checkout outside the repo, and save nothing (for [#900](https://github.com/Tencent/teamai-cli/issues/900)). - Team rules reach Codex, `codex-internal` and `tcodex`. Pull used to copy them to `.codex/rules/.md`, which Codex does not read, and `teamai doctor` reported them delivered. In user scope they now go into a team-rules block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`), beside the culture, shared-instructions and recall blocks. In a project the session-start hook adds the project's rules and those blocks to each session, and to a spawned subagent through a new `SubagentStart` entry, with `additionalContextLimit: 0` so Codex keeps them whole; pull leaves the project `AGENTS.md`, which other tools read too, unchanged. Pull removes the `.md` copies earlier pulls left in the Codex rules directory at its recorded `toolRoots` location, including a publisher's bare local copy, keeping an edited one with a warning. Pull and uninstall keep a removed rule's legacy copy unless it matches its recorded delivery hash; a missing record proves nothing about local edits. A path-scoped rule is rendered without frontmatter after an `Applies to files matching: ` line, in Hermes' `SOUL.md` too. `teamai doctor` checks the user-scope block and, in a project, both hook entries' limit. The public Codex asks once to approve the changed teamai hooks (for [#938](https://github.com/Tencent/teamai-cli/issues/938)). - `teamai pull` names the skills it removes because they are no longer delivered here, in one line, instead of a `debug` line calling them excluded and a summary that says `No resources to sync`. Picking a role or project, or an admin adding `manifest/projects.yaml`, takes root skills away, since the root `skills/` is then the tag catalog; the line then says that `teamai tags subscribe ` brings one back. A namespace skill removed because its namespace is no longer active is named too. The usage guide and the multi-project design no longer say a member with no project still gets `common` or every root item (for [#911](https://github.com/Tencent/teamai-cli/issues/911)). - `teamai pull` keeps the `teamai tags subscribe ` recovery line when the skill directory it removes is byte-identical to an inactive namespace copy: that copy made the namespace cleanup phase remove the directory first, and the hint was lost, because pull inferred whether a root skill had left from which phase did the removing. The hint now follows the repo — it appears exactly when the team repo holds the removed skill at the root, the copy a tag delivers — so a namespace-only skill removed on deactivation is still named without the hint. The usage guide no longer says a member with no role gets no skills at all, in English or Chinese: root skills still arrive through a tag (review of [#917](https://github.com/Tencent/teamai-cli/pull/917)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 9a608ce0c..b69d950dd 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -873,7 +873,7 @@ Choose namespace [1-3] (default: 1 = common): - When several namespaces could take a new resource and there is no terminal to ask on (CI, a hook, `TEAMAI_NONINTERACTIVE`), push stops with exit 2, lists them, and asks for `--role ` - `--role`/`--project` places new resources only. An edit of a shared-root rule or agent stays at the shared root, and push says so - A placed resource stays maintainable from the machine that published it. While its PR is open, the open-PR record routes a later edit of the author's own copy back to that PR; once the file is on the default branch, `state.json` records where push put it, so the edit goes back to the same file, and an agent published into a namespace this directory has not activated is still editable rather than skipped as having no active source -- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, or this machine's placement records cannot be updated and saved, `remove` stops with exit 1 and removes nothing, because either can resolve the name to the wrong files +- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, or this machine's placement records cannot be updated and saved, `remove` stops with exit 1 and removes nothing, because either can resolve the name to the wrong files. `--dry-run` only fetches: it resolves the names against origin's default branch, as the real run does after its pull, and saves no records - A local agent is an edit of the team agent it was delivered from: one in an active namespace first, then one this machine placed, then the shared-root agent either of them replaces. Only when none exists does `--role`/`--project` decide, and the agent is new in that namespace; if that namespace already holds an agent of that name, the agent is skipped rather than written over it, as a rule would be. Two active agents of one name stay ambiguous and are skipped, flag or not. The same agent name may exist in several namespaces, so a copy in an inactive one you did not name never blocks publishing yours. A placed agent that changed on the team since this checkout last synced it is held, because agents have no pre-push sync. Pull keeps your changed copy, so save your edit, delete the copy, run `teamai pull --force`, reapply the edit and push again. In single-repo mode, a root copy under `.teamai/` that matches an older version of the file it was placed at is held too: nothing refreshes it, so it is an old copy rather than an edit - A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` - An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. It replaces a shared-root agent of the same name, as an active namespace's agent would. An active namespace holding that name wins: that agent is the one deployed here diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 31173ab1c..5b3a7d73e 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -779,7 +779,7 @@ Choose namespace [1-3] (default: 1 = common): - 当有多个 namespace 可接收新资源、且没有可供询问的终端(CI、hook、`TEAMAI_NONINTERACTIVE`)时,push 会以退出码 2 停止,列出这些 namespace,并要求使用 `--role ` - `--role`/`--project` 只放置新资源。对共享根目录 rule 或 agent 的修改仍留在共享根目录,push 会给出提示 - 已落点的资源在发布它的机器上仍可维护:PR 未合并期间,待评审 PR 记录会把作者对自己副本的修改带回该 PR;文件进入默认分支后,`state.json` 会记录 push 的落点,因此修改仍会写回同一个文件;即使 agent 落在本目录未激活的 namespace,也不会被当作“无活跃源”跳过 -- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,或本机的落点记录无法更新并保存,`remove` 会以退出码 1 停止且不删除任何内容,因为两者都可能把名字解析到错误的文件 +- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,或本机的落点记录无法更新并保存,`remove` 会以退出码 1 停止且不删除任何内容,因为两者都可能把名字解析到错误的文件。`--dry-run` 只执行 fetch:像真实运行在 pull 之后那样,按 origin 默认分支解析名字,且不保存任何落点记录 - 本地 agent 被视为其来源团队 agent 的编辑:优先是活跃 namespace 中的 agent,其次是本机放置的 agent,最后是被二者替换的共享根目录 agent。只有三者都不存在时,才由 `--role`/`--project` 决定,此时该 agent 在该 namespace 中是新的;若该 namespace 已有同名 agent,则跳过该 agent 而不是覆盖它,与 rule 的处理一致。两个活跃的同名 agent 无论是否指定参数都视为有歧义并跳过。同名 agent 允许存在于多个 namespace,因此你未指定的非活跃 namespace 中的同名副本不会阻止你发布。本机放置的 agent 若在当前检出上次同步后被团队修改,会暂缓推送,因为 agents 没有推送前同步。pull 会保留你修改过的副本,因此请先另存你的修改,删除该副本,执行 `teamai pull --force`,重新应用修改后再 push。单仓库模式下,`.teamai/` 中的根目录副本若与其落点文件的某个旧版本相同,也会暂缓推送:没有任何操作会刷新它,因此它是旧副本而不是编辑 - 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace - 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;它会像活跃 namespace 中的 agent 一样替换共享根目录的同名 agent。若已激活的 namespace 中已有同名 agent,则以它为准 diff --git a/src/__tests__/agents.test.ts b/src/__tests__/agents.test.ts index 21705295e..dd86cb4b1 100644 --- a/src/__tests__/agents.test.ts +++ b/src/__tests__/agents.test.ts @@ -371,19 +371,15 @@ projects: it('resolves the bare agent name to the namespace push recorded for it', async () => { await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\ndescription: Mine\ninstructions: Read it.\n'); - await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + expect(await handler.publishedNameFor('vr', localConfig, { placedAgents: { vr: 'agents/fe/vr.yaml' }, - }); - - expect(await handler.publishedNameFor('vr', localConfig)).toBe('fe/vr'); + })).toBe('fe/vr'); }); it('does not resolve an agent record whose team file is gone', async () => { - await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + expect(await handler.publishedNameFor('vr', localConfig, { placedAgents: { vr: 'agents/fe/vr.yaml' }, - }); - - expect(await handler.publishedNameFor('vr', localConfig)).toBeNull(); + })).toBeNull(); }); it('removes only the named namespace, leaving the same stem elsewhere', async () => { diff --git a/src/__tests__/e2e/dry-run-git-previews-900.test.ts b/src/__tests__/e2e/dry-run-git-previews-900.test.ts index 1bdd99963..785286211 100644 --- a/src/__tests__/e2e/dry-run-git-previews-900.test.ts +++ b/src/__tests__/e2e/dry-run-git-previews-900.test.ts @@ -1,5 +1,5 @@ /** - * E2E (#900 C5): a dry run of `roles add/remove/update` and + * E2E (#900 C1, C5): a dry run of `remove`, `roles add/remove/update` and * `projects add/update/remove` leaves the team clone exactly as it was. * * These commands used to pull before their dry-run guard, and `pullRepo` falls @@ -316,4 +316,15 @@ describe.each([ expect(removed.output).toContain('[dry-run] Would remove project "alpha"'); }); + it('remove keeps the local commits and state, and resolves names against origin', async () => { + const fixture = setUp(); + + // fresh.md exists only on origin, so a stale checkout would answer "not found". + const result = await dryRun(fixture, ['remove', 'rules', 'fresh', 'doomed']); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain('Will remove 2 rules:'); + expect(result.output).toContain(' - fresh'); + expect(result.output).toContain(' - doomed'); + expect(result.output).toContain('Dry run — no changes made'); + }); }); diff --git a/src/__tests__/remove.test.ts b/src/__tests__/remove.test.ts index 5d5e1933d..ebeaadb8d 100644 --- a/src/__tests__/remove.test.ts +++ b/src/__tests__/remove.test.ts @@ -109,13 +109,13 @@ scope: 'user', ); mockState.placedRules = { 'my-rule': 'rules/fe-know/my-rule.md' }; - expect(await handler.publishedNameFor('my-rule', localConfig)).toBe('fe-know/my-rule'); + expect(await handler.publishedNameFor('my-rule', localConfig, mockState)).toBe('fe-know/my-rule'); }); it('does not resolve a record whose team file is gone', async () => { mockState.placedRules = { 'my-rule': 'rules/fe-know/my-rule.md' }; - expect(await handler.publishedNameFor('my-rule', localConfig)).toBeNull(); + expect(await handler.publishedNameFor('my-rule', localConfig, mockState)).toBeNull(); }); it('does not resolve a rule this machine never placed', async () => { @@ -123,7 +123,7 @@ scope: 'user', path.join(localConfig.repo.localPath, 'rules', 'fe-know', 'my-rule.md'), 'team content', ); - expect(await handler.publishedNameFor('my-rule', localConfig)).toBeNull(); + expect(await handler.publishedNameFor('my-rule', localConfig, mockState)).toBeNull(); }); it('tombstones only the published name, even when the record makes the root copy ours', async () => { diff --git a/src/remove.ts b/src/remove.ts index 760acd91e..fe2e16437 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -6,7 +6,7 @@ import { pullRepo, pushRepoBranch, checkoutMaster, generateBranchName } from './ import { createPrWithFallback, filterExistingTopLevelPaths } from './push.js'; import { log, spinner } from './utils/logger.js'; import { getHandler } from './resources/index.js'; -import type { GlobalOptions, ResourceType, LocalConfig, TeamaiConfig } from './types.js'; +import type { GlobalOptions, ResourceType, LocalConfig, TeamaiConfig, State } from './types.js'; import { askConfirmation } from './utils/prompt.js'; const REMOVABLE_TYPES: ResourceType[] = ['skills', 'rules', 'agents', 'mcp']; @@ -39,11 +39,14 @@ export async function remove( assertNotReadOnly(localConfig, 'teamai remove'); // Single-repo mode: run the removal PR in an isolated knowledge worktree so the - // branch/commit never touches the user's active tree. - if (localConfig.repo.kind === 'self') { - const { withKnowledgeWorktree, EmptyRepoError } = await import('./utils/reports-branch.js'); + // branch/commit never touches the user's active tree. A dry run reads a + // throwaway checkout of origin/ instead, in either mode: the pull + // below can reset a clone with unpushed commits (#900). + if (options.dryRun || localConfig.repo.kind === 'self') { + const { withKnowledgeWorktree, withDefaultBranchPreview, EmptyRepoError } = await import('./utils/reports-branch.js'); + const withCheckout = options.dryRun ? withDefaultBranchPreview : withKnowledgeWorktree; try { - await withKnowledgeWorktree(localConfig, (wtConfig) => removeCore(type, names, options, wtConfig, teamConfig)); + await withCheckout(localConfig, (checkoutConfig) => removeCore(type, names, options, checkoutConfig, teamConfig)); } catch (e) { if (e instanceof EmptyRepoError) { log.error(e.message); @@ -64,15 +67,13 @@ async function removeCore( localConfig: LocalConfig, teamConfig: TeamaiConfig, ): Promise { - const selfMode = localConfig.repo.kind === 'self'; - - // Pull latest before making changes. In self mode the worktree is already a - // fresh checkout of origin/, so skip the pull. + // Pull latest before making changes. In self mode and on a dry run the + // checkout is already a fresh copy of origin/, so skip the pull. // A clone that could not be refreshed is not the default branch: a placement // merged since the last pull is not recorded there, so the bare name the // author types falls back to the stem and removes that agent from every // namespace (#649 review). Removing is a write, so stop instead of guessing. - if (!selfMode) { + if (localConfig.repo.kind !== 'self' && !options.dryRun) { try { await pullRepo(localConfig.repo.localPath); } catch (e) { @@ -89,12 +90,15 @@ async function removeCore( // `publishedNameFor` below resolves the bare name the author types through // the placement record, and a placement becomes a record only once it has // landed on the default branch — which this may be the first command to see. - // Not best-effort here: `publishedNameFor` reads the records back from disk, - // so a placement that merged but could not be saved as a record resolves to - // the bare stem — and that removes the agent from every namespace. + // The reconciled records are handed to `publishedNameFor` rather than read + // back from disk, so a dry run resolves the same names without saving them. + // Not best-effort here: a placement that merged but is not a record resolves + // to the bare stem — and that removes the agent from every namespace. + let recordsState: State; try { - const recordsState = await loadStateForScope(localConfig); - if (await reconcilePlacementRecords(localConfig.repo.localPath, recordsState, undefined, () => deliversEveryNamespace(localConfig))) { + recordsState = await loadStateForScope(localConfig); + if (await reconcilePlacementRecords(localConfig.repo.localPath, recordsState, undefined, () => deliversEveryNamespace(localConfig)) + && !options.dryRun) { await saveStateForScope(recordsState, localConfig); } } catch (e) { @@ -133,7 +137,7 @@ async function removeCore( // contributes that bare name whenever their copy has edits. Taking the // bare match would delete the local copy, report success, and leave the // namespaced team file published (#649 review). - const published = await handler.publishedNameFor(name, localConfig); + const published = await handler.publishedNameFor(name, localConfig, recordsState); if (published) { // Not cross-checked against `allNames`: `publishedNameFor` has already // proved the file is in the team repo, and the scans do not all spell a diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 33b08a762..f49a0ad8c 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { isDeepStrictEqual } from 'node:util'; import { parse as parseYaml } from 'yaml'; import matter from 'gray-matter'; -import { isToolInstalledForConfig, ResourceHandler, type ScanForPushOptions } from './base.js'; +import { isToolInstalledForConfig, ResourceHandler, type PlacementRecords, type ScanForPushOptions } from './base.js'; import type { ResourceItem, ResourceItemStatus, DeliveryTarget, TeamaiConfig, LocalConfig, AgentModelRecords, RecordedAgentModel } from '../types.js'; import { listFiles, listDirs, pathExists, copyFile, ensureDir, remove, fileContentEqual, getFileMtime, writeFile, readFileSafe, fileHash } from '../utils/fs.js'; import { log } from '../utils/logger.js'; @@ -762,9 +762,9 @@ export class AgentsHandler extends ResourceHandler { * this, `remove` matched that bare name and deleted every `vr` in every * namespace — other people's agents included (#649 review). */ - async publishedNameFor(name: string, localConfig: LocalConfig): Promise { + async publishedNameFor(name: string, localConfig: LocalConfig, records: PlacementRecords): Promise { const placed = placedResourcePath( - (await loadStateForScope(localConfig)).placedAgents, 'agents', name, + records.placedAgents, 'agents', name, ); if (!placed) return null; if (!await pathExists(path.join(localConfig.repo.localPath, placed))) return null; diff --git a/src/resources/base.ts b/src/resources/base.ts index cff7d034b..79919a0d4 100644 --- a/src/resources/base.ts +++ b/src/resources/base.ts @@ -1,6 +1,9 @@ import path from 'node:path'; import { COPILOT_TOOL_ID, getCopilotHome, resolveToolBaseDir, toolInstallRoot } from '../types.js'; -import type { ResourceType, ResourceItem, ResourceDiff, DeliveryTarget, TeamaiConfig, LocalConfig } from '../types.js'; +import type { ResourceType, ResourceItem, ResourceDiff, DeliveryTarget, TeamaiConfig, LocalConfig, State } from '../types.js'; + +/** The placement records `publishedNameFor` resolves a bare name through. */ +export type PlacementRecords = Pick; import { readFileSafe, writeFile, ensureDir, pathExists } from '../utils/fs.js'; import { getUserHome } from '../utils/home.js'; import type { DeliveryLedger } from './delivered-copies.js'; @@ -105,9 +108,10 @@ export abstract class ResourceHandler { * placed resource lives at `//`; the author's local copy is * still at the resource root, so they know it by its bare name and `remove` * would answer "not found". Handlers that keep a placement record resolve it - * here. Returns null when there is nothing to translate. + * here, from `records` as the caller reconciled them. Returns null when there + * is nothing to translate. */ - async publishedNameFor(_name: string, _localConfig: LocalConfig): Promise { + async publishedNameFor(_name: string, _localConfig: LocalConfig, _records: PlacementRecords): Promise { return null; } diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 86bc81e54..622e32212 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { isToolInstalledForConfig, ResourceHandler } from './base.js'; +import { isToolInstalledForConfig, ResourceHandler, type PlacementRecords } from './base.js'; import type { ResourceItem, ResourceItemStatus, DeliveryTarget, TeamaiConfig, LocalConfig } from '../types.js'; import { listFilesRecursive, pathExists, copyFile, ensureDir, remove, fileContentEqual, getFileMtime, listDirs, readFileSafe, writeFile, pruneEmptyDirs, fileHash } from '../utils/fs.js'; import { log } from '../utils/logger.js'; @@ -521,9 +521,9 @@ export class RulesHandler extends ResourceHandler { * `my-rule` when push placed it at `rules/fe-know/my-rule.md`: the author * types the name their local copy has, which is the bare one. */ - async publishedNameFor(name: string, localConfig: LocalConfig): Promise { + async publishedNameFor(name: string, localConfig: LocalConfig, records: PlacementRecords): Promise { const placed = placedResourcePath( - (await loadStateForScope(localConfig)).placedRules, 'rules', name, + records.placedRules, 'rules', name, ); if (!placed) return null; if (!await pathExists(path.join(localConfig.repo.localPath, placed))) return null; From d452701122c25b51a547b51c912d257fb8efaebe Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Sat, 3 Oct 2026 17:06:24 +0200 Subject: [PATCH 3/8] fix(dry-run): preview import --from-repo without cloning (#900) A dry run of import --from-repo and --from-repo-list deleted and re-cloned the repo cache (or fetched and reset it), took the import lock and ran the LLM scan. It now reads the remote head with git ls-remote, using the clone's auth (resolveCloneAuth, extracted from shallowClone), compares it with LAST_SYNC and prints "Would import / at " with the cache state, then stops. Refs #900 (C3) --- CHANGELOG.md | 1 + docs/usage-guide.md | 2 + docs/usage-guide.zh-CN.md | 2 + .../e2e/import-repo-dry-run-900.test.ts | 176 ++++++++++++++++++ src/clone.ts | 98 +++++++--- src/import-repo.ts | 23 ++- 6 files changed, 270 insertions(+), 32 deletions(-) create mode 100644 src/__tests__/e2e/import-repo-dry-run-900.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c688ad33f..39b4ff4e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ All notable changes to this project will be documented in this file. See [standa - A user pull now repairs Hermes' `SOUL.md` rules block also when the team repo has not moved, so a block an older release's project pull overwrote is fixed by the first pull after upgrading; `doctor` says `teamai pull` for it (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - `teamai init` prints the Codex trust reminder when it installs hooks for the public Codex, and the reminder says that until the hooks are trusted a project's team rules do not reach Codex (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - When no team rule reaches a member any more, pull also removes a copy an older release wrote before the tool got its own rules format (a verbatim Kiro, Qoder, CodeBuddy, WorkBuddy or Oh My Pi copy, a Cursor render in JoyCode), not only one in the current format (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- `teamai import --from-repo` and `--from-repo-list` with `--dry-run` no longer clone, fetch or reset the repo cache, take the import lock or run the AI scan. They read each repo's head with `git ls-remote` and print `Would import / at ` with whether the cache is current (for [#900](https://github.com/Tencent/teamai-cli/issues/900)). - `--dry-run` no longer discards commits you have not pushed. `teamai remove`, `teamai roles init/add/remove/update` and `teamai projects add/update/remove` pulled the team repo before their dry-run check, and a pull that cannot fast-forward resets the clone to origin; `remove` also saved this machine's placement records, and in single-repo mode both created the knowledge worktree. A dry run no longer pulls: `remove`, `roles add/remove/update` and `projects add/update/remove` fetch and read origin's default branch from a temporary checkout outside the repo, and save nothing (for [#900](https://github.com/Tencent/teamai-cli/issues/900)). - Team rules reach Codex, `codex-internal` and `tcodex`. Pull used to copy them to `.codex/rules/.md`, which Codex does not read, and `teamai doctor` reported them delivered. In user scope they now go into a team-rules block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`), beside the culture, shared-instructions and recall blocks. In a project the session-start hook adds the project's rules and those blocks to each session, and to a spawned subagent through a new `SubagentStart` entry, with `additionalContextLimit: 0` so Codex keeps them whole; pull leaves the project `AGENTS.md`, which other tools read too, unchanged. Pull removes the `.md` copies earlier pulls left in the Codex rules directory at its recorded `toolRoots` location, including a publisher's bare local copy, keeping an edited one with a warning. Pull and uninstall keep a removed rule's legacy copy unless it matches its recorded delivery hash; a missing record proves nothing about local edits. A path-scoped rule is rendered without frontmatter after an `Applies to files matching: ` line, in Hermes' `SOUL.md` too. `teamai doctor` checks the user-scope block and, in a project, both hook entries' limit. The public Codex asks once to approve the changed teamai hooks (for [#938](https://github.com/Tencent/teamai-cli/issues/938)). - `teamai pull` names the skills it removes because they are no longer delivered here, in one line, instead of a `debug` line calling them excluded and a summary that says `No resources to sync`. Picking a role or project, or an admin adding `manifest/projects.yaml`, takes root skills away, since the root `skills/` is then the tag catalog; the line then says that `teamai tags subscribe ` brings one back. A namespace skill removed because its namespace is no longer active is named too. The usage guide and the multi-project design no longer say a member with no project still gets `common` or every root item (for [#911](https://github.com/Tencent/teamai-cli/issues/911)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index b69d950dd..af9ae50ac 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1922,6 +1922,8 @@ teamai import --from-repo https://github.com/org/repo --skip-enrich If core graph extraction or writing fails, the import reports an error without marking the commit as synced. The next incremental run retries that commit. +With `--dry-run`, `--from-repo` and `--from-repo-list` read each repo's head with `git ls-remote`, print `Would import / at ` and whether the local cache is current, and stop there: nothing is cloned or fetched into the cache, the import lock is not taken, and no AI step runs. + `--from-mr` publishes its learning the way `teamai contribute` does, on the `teamai-learnings` branch: under `learnings//` when exactly one active project declares a learnings namespace, otherwise at the shared `learnings/` root. If that fails, the learning stays queued on this machine and the next `teamai pull` publishes it; when a learnings checkout teamai refuses stopped it, no pull can until you deal with that checkout as the message says. When the draft overlaps existing learnings, from the shared root or your active projects' namespaces, the command names them (`Possible duplicate: this learning overlaps N existing learning(s): .`), with `--all` too. It is a notice only: nothing is marked or replaced. When `manifest/projects.yaml` cannot be read, the check compares the shared root only and says so. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 5b3a7d73e..1a2df7ff3 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1767,6 +1767,8 @@ teamai import --from-repo https://github.com/org/repo --skip-enrich 如果核心知识图谱提取或写入失败,导入会报错,且不会将该提交标记为已同步。下次增量导入会重试该提交。 +加 `--dry-run` 时,`--from-repo` 与 `--from-repo-list` 用 `git ls-remote` 读取每个仓库的最新提交,打印 `Would import / at ` 以及本地缓存是否最新,然后停止:不会 clone 或 fetch 到缓存,不会获取导入锁,也不会运行任何 AI 步骤。 + `--from-mr` 与 `teamai contribute` 一样,把提取的经验发布到 `teamai-learnings` 分支:恰好一个激活项目声明了 learnings namespace 时放在 `learnings//` 下,否则放在共享的 `learnings/` 根目录。发布失败时,经验留在本机队列中,下次 `teamai pull` 会发布它;若阻止发布的是 teamai 拒绝使用的 learnings 检出,则在你按提示处理该检出之前,任何 pull 都无法发布它。 如果草稿与已有经验(共享根目录或当前激活项目的 namespace 中的)高度重叠,命令会列出这些文件(`Possible duplicate: this learning overlaps N existing learning(s): .`),使用 `--all` 时同样如此。这只是提示:不会标记或替换任何已有经验。`manifest/projects.yaml` 无法读取时,只与共享根目录比较,并给出提示。 diff --git a/src/__tests__/e2e/import-repo-dry-run-900.test.ts b/src/__tests__/e2e/import-repo-dry-run-900.test.ts new file mode 100644 index 000000000..b59faf8c9 --- /dev/null +++ b/src/__tests__/e2e/import-repo-dry-run-900.test.ts @@ -0,0 +1,176 @@ +/** + * E2E (#900 C3): `teamai import --from-repo` and `--from-repo-list` with + * `--dry-run` read the remote head and stop. + * + * A dry run used to delete and re-clone the repo cache (or fetch and reset + * it), take the import lock and run the LLM scan. A preview may ask the + * remote for its head with `git ls-remote`; it must leave the cache, the lock + * and the team repo alone, and say whether the cache is current. + */ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { execFileSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..', '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +const GIT_ENV = { + GIT_AUTHOR_NAME: 'TeamAI CI', + GIT_AUTHOR_EMAIL: 'ci@teamai.test', + GIT_COMMITTER_NAME: 'TeamAI CI', + GIT_COMMITTER_EMAIL: 'ci@teamai.test', +}; + +/** A host no provider claims, so the import uses plain git and no token lookup. */ +const URL = 'https://git.example.com/acme/widget.git'; + +interface RunResult { + code: number | null; + output: string; +} + +function git(args: string[], cwd: string): string { + return execFileSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, ...GIT_ENV } }); +} + +/** Every file under `dir` with its contents, so any write shows up as a diff. */ +function tree(dir: string): Record | null { + if (!fs.existsSync(dir)) return null; + const files: Record = {}; + for (const rel of fs.readdirSync(dir, { recursive: true }).map(String).sort()) { + const abs = path.join(dir, rel); + files[rel] = fs.statSync(abs).isFile() ? fs.readFileSync(abs, 'base64') : ''; + } + return files; +} + +describe('import --from-repo --dry-run (#900 C3)', () => { + let sandbox: string; + let home: string; + let cacheRoot: string; + let cacheDir: string; + let teamRepo: string; + let remote: string; + let head: string; + + function runCLI(args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn('node', [CLI, ...args], { + cwd: home, + env: { + ...process.env, + ...GIT_ENV, + HOME: home, + USERPROFILE: home, + FORCE_COLOR: '0', + TEAMAI_CACHE_DIR: cacheRoot, + // Send the made-up URL to the local bare repo. + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: `url.${remote}.insteadOf`, + GIT_CONFIG_VALUE_0: URL, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout.on('data', (data: Buffer) => { output += data.toString(); }); + child.stderr.on('data', (data: Buffer) => { output += data.toString(); }); + child.on('close', (code) => resolve({ code, output })); + }); + } + + /** What a dry run must leave as it was: the cache, the team repo and the lock beside it. */ + function snapshot() { + return { + cache: tree(cacheRoot), + teamRepo: tree(teamRepo), + lock: fs.existsSync(path.join(path.dirname(teamRepo), '.teamai-import.lock')), + }; + } + + async function dryRun(args: string[]): Promise { + const before = snapshot(); + const result = await runCLI([...args, '--dry-run']); + expect(snapshot(), result.output).toEqual(before); + return result; + } + + beforeAll(() => { + if (!fs.existsSync(CLI)) { + throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + } + }); + + beforeEach(() => { + sandbox = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-900-import-'))); + home = path.join(sandbox, 'home'); + cacheRoot = path.join(sandbox, 'cache'); + cacheDir = path.join(cacheRoot, 'git', 'acme', 'widget'); + teamRepo = path.join(home, '.teamai', 'team-repo'); + remote = path.join(sandbox, 'widget.git'); + + const seed = path.join(sandbox, 'seed'); + fs.mkdirSync(seed, { recursive: true }); + fs.writeFileSync(path.join(seed, 'index.ts'), 'export const widget = 1;\n'); + git(['init', '-q', '-b', 'main'], seed); + git(['add', '-A'], seed); + git(['commit', '-q', '-m', 'widget'], seed); + git(['clone', '-q', '--bare', seed, remote], sandbox); + head = git(['rev-parse', 'HEAD'], seed).trim(); + + fs.mkdirSync(teamRepo, { recursive: true }); + fs.writeFileSync(path.join(teamRepo, 'teamai.yaml'), 'team: issue-900-import\nrepo: local/team\nprovider: git\n'); + fs.writeFileSync(path.join(home, '.teamai', 'config.yaml'), [ + 'repo:', + ` localPath: ${teamRepo}`, + ' remote: local/team', + 'username: e2e', + 'updatePolicy: skip', + 'scope: user', + '', + ].join('\n')); + }); + + afterEach(() => { + fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + /** A cache from an earlier import, synced at `sha`. */ + function seedCache(sha: string): void { + fs.mkdirSync(path.dirname(cacheDir), { recursive: true }); + git(['clone', '-q', remote, cacheDir], sandbox); + fs.writeFileSync(path.join(cacheDir, 'LAST_SYNC'), `${sha}\n2026-01-01T00:00:00.000Z\n`); + } + + it('does not clone a repo that has no cache yet', async () => { + const result = await dryRun(['import', '--from-repo', URL]); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain(`[dry-run] Would import acme/widget at ${head.slice(0, 8)}`); + expect(result.output).toContain('not cached yet'); + }); + + it('says the cache is current without fetching into it', async () => { + seedCache(head); + const result = await dryRun(['import', '--from-repo', URL]); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain(`cache is current at ${head.slice(0, 8)}`); + }); + + it('says a stale cache would be refreshed, and leaves it stale', async () => { + seedCache('0'.repeat(40)); + const result = await dryRun(['import', '--from-repo', URL, '--incremental']); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain(`cache would be refreshed from 00000000 to ${head.slice(0, 8)}`); + }); + + it('previews every entry of --from-repo-list', async () => { + const list = path.join(sandbox, 'repos.yaml'); + fs.writeFileSync(list, `repos:\n - url: ${URL}\n`); + const result = await dryRun(['import', '--from-repo-list', list]); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain(`[dry-run] Would import acme/widget at ${head.slice(0, 8)}`); + }); +}); diff --git a/src/clone.ts b/src/clone.ts index c69e0b58d..7b993777f 100644 --- a/src/clone.ts +++ b/src/clone.ts @@ -133,39 +133,17 @@ async function gitCmd( return stdout.trim(); } -// ─── Public API ───────────────────────────────────────── - /** - * Shallow clone 远端仓库到指定本地目录。 - * - * 三层认证策略: - * 1. forceSsh=true 或 url 是 SSH 形式 → 直接走 SSH - * 2. github 且能拿到 token → HTTPS + x-access-token 注入 - * 3. 其他 provider 交给 Git credential helper / ~/.netrc 处理;github 无 token 则匿名 HTTPS - * - * @param url 仓库 URL(https/ssh 任一) - * @param localPath 目标目录(存在则先 rm 再 clone) - * @param provider Provider name such as 'github', 'tgit', 'cnb', or 'git' - * @param opts 克隆选项 + * The URL and auth a clone of `url` uses: SSH, an `http.extraHeader` token, or + * the URL as given for Git's own credential helper. Shared with + * {@link remoteHeadSha}, so a dry run asks the remote the way the clone would. */ -export async function shallowClone( +function resolveCloneAuth( url: string, - localPath: string, provider: string, - opts?: CloneOpts, -): Promise { - const depth = opts?.depth ?? 1; - const forceSsh = opts?.forceSsh ?? false; - const forceAnonymous = opts?.forceAnonymous ?? false; - const timeoutMs = opts?.timeoutMs ?? 180_000; - - // 清理已存在目录 - if (await fs.pathExists(localPath)) { - await fs.remove(localPath); - } - await fs.ensureDir(localPath); - - // 确定克隆 URL 和认证方式 + opts: { forceSsh: boolean; forceAnonymous: boolean }, +): { cloneUrl: string; cloneMethod: CloneResult['cloneMethod']; extraAuthHeader?: string } { + const { forceSsh, forceAnonymous } = opts; let cloneUrl = url; let cloneMethod: CloneResult['cloneMethod']; let extraAuthHeader: string | undefined; @@ -240,6 +218,43 @@ export async function shallowClone( log.debug(`shallowClone: 使用 HTTPS (Git credential helper / ~/.netrc) 克隆 ${provider} 仓库`); } + return { cloneUrl, cloneMethod, extraAuthHeader }; +} + +// ─── Public API ───────────────────────────────────────── + +/** + * Shallow clone 远端仓库到指定本地目录。 + * + * 三层认证策略: + * 1. forceSsh=true 或 url 是 SSH 形式 → 直接走 SSH + * 2. github 且能拿到 token → HTTPS + x-access-token 注入 + * 3. 其他 provider 交给 Git credential helper / ~/.netrc 处理;github 无 token 则匿名 HTTPS + * + * @param url 仓库 URL(https/ssh 任一) + * @param localPath 目标目录(存在则先 rm 再 clone) + * @param provider Provider name such as 'github', 'tgit', 'cnb', or 'git' + * @param opts 克隆选项 + */ +export async function shallowClone( + url: string, + localPath: string, + provider: string, + opts?: CloneOpts, +): Promise { + const depth = opts?.depth ?? 1; + const forceSsh = opts?.forceSsh ?? false; + const forceAnonymous = opts?.forceAnonymous ?? false; + const timeoutMs = opts?.timeoutMs ?? 180_000; + + // 清理已存在目录 + if (await fs.pathExists(localPath)) { + await fs.remove(localPath); + } + await fs.ensureDir(localPath); + + const { cloneUrl, cloneMethod, extraAuthHeader } = resolveCloneAuth(url, provider, { forceSsh, forceAnonymous }); + // 构建 clone 参数:若有 token 则通过 http.extraHeader 注入,避免 token 出现在 URL 中 const cloneArgs: string[] = []; if (extraAuthHeader) { @@ -335,3 +350,28 @@ export async function shallowFetch( const sha = await gitCmd(['rev-parse', 'HEAD'], localPath); return { sha }; } + +/** + * The commit the remote's default branch (HEAD) points at, read with + * `git ls-remote` and the same auth {@link shallowClone} would use. Nothing is + * written locally, which is what a dry run needs. + */ +export async function remoteHeadSha( + url: string, + provider: string, + opts?: Pick, +): Promise { + const { cloneUrl, extraAuthHeader } = resolveCloneAuth(url, provider, { + forceSsh: opts?.forceSsh ?? false, + forceAnonymous: opts?.forceAnonymous ?? false, + }); + const args = extraAuthHeader ? ['-c', `http.extraHeader=${extraAuthHeader}`] : []; + args.push('ls-remote', cloneUrl, 'HEAD'); + const { stdout, stderr, code } = await runCommand('git', args, { timeoutMs: opts?.timeoutMs ?? 60_000 }); + if (code !== 0) { + throw new Error(`git ls-remote failed (exit ${code}): ${redactToken(stderr.trim())}`); + } + const sha = stdout.trim().split(/\s+/)[0]; + if (!sha) throw new Error(`git ls-remote found no HEAD at ${sanitizeGitUrl(url)}`); + return sha; +} diff --git a/src/import-repo.ts b/src/import-repo.ts index e0c69c5b2..223550e28 100644 --- a/src/import-repo.ts +++ b/src/import-repo.ts @@ -5,7 +5,7 @@ import chalk from 'chalk'; import { generateCodebaseMd } from './codebase.js'; import { extractCodebase } from './codebase-extract.js'; import { detectProvider, getProvider } from './providers/registry.js'; -import { shallowClone, shallowFetch } from './clone.js'; +import { remoteHeadSha, shallowClone, shallowFetch } from './clone.js'; import { getRepoCacheDir, getRepoSlug, @@ -30,7 +30,7 @@ export interface ImportFromRepoOptions { forceAnonymous?: boolean; /** Skip AI recommendation when --domain is explicitly set */ explicitDomain?: string; - /** Dry-run mode: skip writing to disk but still execute clone+scan */ + /** Dry-run mode: read the remote head with ls-remote and stop before the clone, the lock and the LLM scan */ dryRun?: boolean; /** Custom output root directory; defaults to .teamai/team-repo/teamwiki */ output?: string; @@ -182,6 +182,9 @@ export function detectCrossRepoEdges( * 5. Append AI narrative to teamwiki/evidence/code//overview.md * 6. Write LAST_SYNC * + * A dry run stops after step 1: it reads the remote head with `git ls-remote` + * and reports whether the cache is current. + * * @throws Error on clone failure, scan failure, or IO failure */ export async function importFromRepo(opts: ImportFromRepoOptions): Promise { @@ -204,12 +207,26 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise log.info(`Importing remote repo: ${owner}/${repoName} (provider: ${providerName})`); // 2. Shallow clone or incremental fetch+reset - await ensureCacheRoot(); const cacheDir = getRepoCacheDir(providerName, owner, repoName); const slug = getRepoSlug(providerName, owner, repoName); const lastSync = await readLastSync(cacheDir); const cacheExists = await fs.pathExists(path.join(cacheDir, '.git')); + + // A preview asks the remote for its head and stops: the clone replaces the + // cache (or fetches and resets it), and the lock and LLM scan follow (#900). + if (dryRun) { + const head = await remoteHeadSha(url, providerName, { forceSsh, forceAnonymous }); + const cache = !cacheExists + ? `not cached yet, a real run would clone it into ${cacheDir}` + : lastSync?.sha === head + ? `cache is current at ${head.slice(0, 8)}${incremental ? ', so an incremental run would skip it' : ''}` + : `cache would be refreshed from ${lastSync ? lastSync.sha.slice(0, 8) : 'an unrecorded commit'} to ${head.slice(0, 8)}`; + log.info(`[dry-run] Would import ${owner}/${repoName} at ${head.slice(0, 8)} into teamwiki/evidence/code/${slug}; ${cache}`); + return; + } + + await ensureCacheRoot(); const useIncremental = incremental && cacheExists && lastSync !== null; let cloneSha: string; From bd6adc73b2bbdb33a84774e65495e75c9ad327c7 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Sun, 4 Oct 2026 11:34:31 +0200 Subject: [PATCH 4/8] fix(dry-run): preserve refresh refusal and roles init ordering (#900) --- docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 4 +- .../e2e/dry-run-git-previews-900.test.ts | 29 +++++++++++ src/__tests__/roles-init-refresh.test.ts | 50 +++++++++++++++++++ src/manifest-edit.ts | 13 +++-- src/remove.ts | 30 +++++++---- src/roles-cmd.ts | 2 +- src/utils/reports-branch.ts | 10 ++++ 8 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/roles-init-refresh.test.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index af9ae50ac..78b174da0 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -873,7 +873,7 @@ Choose namespace [1-3] (default: 1 = common): - When several namespaces could take a new resource and there is no terminal to ask on (CI, a hook, `TEAMAI_NONINTERACTIVE`), push stops with exit 2, lists them, and asks for `--role ` - `--role`/`--project` places new resources only. An edit of a shared-root rule or agent stays at the shared root, and push says so - A placed resource stays maintainable from the machine that published it. While its PR is open, the open-PR record routes a later edit of the author's own copy back to that PR; once the file is on the default branch, `state.json` records where push put it, so the edit goes back to the same file, and an agent published into a namespace this directory has not activated is still editable rather than skipped as having no active source -- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, or this machine's placement records cannot be updated and saved, `remove` stops with exit 1 and removes nothing, because either can resolve the name to the wrong files. `--dry-run` only fetches: it resolves the names against origin's default branch, as the real run does after its pull, and saves no records +- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, or this machine's placement records cannot be updated and saved, `remove` stops with exit 1 and removes nothing, because either can resolve the name to the wrong files. `--dry-run` only fetches: it resolves the names against origin's default branch, as the real run does after its pull, and saves no records. In clone mode, a failed fetch stops the preview with the same refusal and exit 1 as a real removal. - A local agent is an edit of the team agent it was delivered from: one in an active namespace first, then one this machine placed, then the shared-root agent either of them replaces. Only when none exists does `--role`/`--project` decide, and the agent is new in that namespace; if that namespace already holds an agent of that name, the agent is skipped rather than written over it, as a rule would be. Two active agents of one name stay ambiguous and are skipped, flag or not. The same agent name may exist in several namespaces, so a copy in an inactive one you did not name never blocks publishing yours. A placed agent that changed on the team since this checkout last synced it is held, because agents have no pre-push sync. Pull keeps your changed copy, so save your edit, delete the copy, run `teamai pull --force`, reapply the edit and push again. In single-repo mode, a root copy under `.teamai/` that matches an older version of the file it was placed at is held too: nothing refreshes it, so it is an old copy rather than an edit - A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` - An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. It replaces a shared-root agent of the same name, as an active namespace's agent would. An active namespace holding that name wins: that agent is the one deployed here @@ -936,7 +936,7 @@ teamai roles remove devops teamai roles add test --namespaces common,test --dry-run ``` -The `--namespaces` list is applied to `knowledge`, `skills` and `agents` alike. The commands above automatically push a branch and create an MR; the change takes effect team-wide once merged. With `--dry-run`, `teamai roles add/update/remove` and `teamai projects add/update/remove` fetch and read the manifest on origin's default branch; they do not pull the team repo or, in single-repo mode, create a worktree, so commits you have not pushed stay. +The `--namespaces` list is applied to `knowledge`, `skills` and `agents` alike. The commands above automatically push a branch and create an MR; the change takes effect team-wide once merged. With `--dry-run`, `teamai roles add/update/remove` and `teamai projects add/update/remove` fetch and read the manifest on origin's default branch; they do not pull the team repo or, in single-repo mode, create a worktree, so commits you have not pushed stay. If fetching fails, these manifest previews warn and use the last fetched copy, matching a real edit's warn-and-proceed policy. In clone mode, `roles init` pulls once before checking for an existing manifest and asking questions. It does not pull again before writing. **Member operations:** diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 1a2df7ff3..df6449dd6 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -779,7 +779,7 @@ Choose namespace [1-3] (default: 1 = common): - 当有多个 namespace 可接收新资源、且没有可供询问的终端(CI、hook、`TEAMAI_NONINTERACTIVE`)时,push 会以退出码 2 停止,列出这些 namespace,并要求使用 `--role ` - `--role`/`--project` 只放置新资源。对共享根目录 rule 或 agent 的修改仍留在共享根目录,push 会给出提示 - 已落点的资源在发布它的机器上仍可维护:PR 未合并期间,待评审 PR 记录会把作者对自己副本的修改带回该 PR;文件进入默认分支后,`state.json` 会记录 push 的落点,因此修改仍会写回同一个文件;即使 agent 落在本目录未激活的 namespace,也不会被当作“无活跃源”跳过 -- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,或本机的落点记录无法更新并保存,`remove` 会以退出码 1 停止且不删除任何内容,因为两者都可能把名字解析到错误的文件。`--dry-run` 只执行 fetch:像真实运行在 pull 之后那样,按 origin 默认分支解析名字,且不保存任何落点记录 +- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,或本机的落点记录无法更新并保存,`remove` 会以退出码 1 停止且不删除任何内容,因为两者都可能把名字解析到错误的文件。`--dry-run` 只执行 fetch:像真实运行在 pull 之后那样,按 origin 默认分支解析名字,且不保存任何落点记录。克隆模式下 fetch 失败时,预览会使用与真实删除相同的拒绝消息,并以退出码 1 停止。 - 本地 agent 被视为其来源团队 agent 的编辑:优先是活跃 namespace 中的 agent,其次是本机放置的 agent,最后是被二者替换的共享根目录 agent。只有三者都不存在时,才由 `--role`/`--project` 决定,此时该 agent 在该 namespace 中是新的;若该 namespace 已有同名 agent,则跳过该 agent 而不是覆盖它,与 rule 的处理一致。两个活跃的同名 agent 无论是否指定参数都视为有歧义并跳过。同名 agent 允许存在于多个 namespace,因此你未指定的非活跃 namespace 中的同名副本不会阻止你发布。本机放置的 agent 若在当前检出上次同步后被团队修改,会暂缓推送,因为 agents 没有推送前同步。pull 会保留你修改过的副本,因此请先另存你的修改,删除该副本,执行 `teamai pull --force`,重新应用修改后再 push。单仓库模式下,`.teamai/` 中的根目录副本若与其落点文件的某个旧版本相同,也会暂缓推送:没有任何操作会刷新它,因此它是旧副本而不是编辑 - 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace - 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;它会像活跃 namespace 中的 agent 一样替换共享根目录的同名 agent。若已激活的 namespace 中已有同名 agent,则以它为准 @@ -837,7 +837,7 @@ teamai roles remove devops teamai roles add test --namespaces common,test --dry-run ``` -`--namespaces` 列表会同时应用到 `knowledge`、`skills` 与 `agents`。以上命令会自动 push 分支并创建 MR,合并后对全团队生效。加 `--dry-run` 时,`teamai roles add/update/remove` 与 `teamai projects add/update/remove` 只 fetch 并读取 origin 默认分支上的 manifest,不会 pull 团队仓库,单仓模式下也不会创建 worktree,因此尚未推送的提交会保留。 +`--namespaces` 列表会同时应用到 `knowledge`、`skills` 与 `agents`。以上命令会自动 push 分支并创建 MR,合并后对全团队生效。加 `--dry-run` 时,`teamai roles add/update/remove` 与 `teamai projects add/update/remove` 只 fetch 并读取 origin 默认分支上的 manifest,不会 pull 团队仓库,单仓模式下也不会创建 worktree,因此尚未推送的提交会保留。若 fetch 失败,这些 manifest 预览会警告并使用上次获取的副本,与真实编辑在 pull 失败后警告并继续的策略一致。克隆模式下,`roles init` 只在检查已有 manifest 和交互提问之前 pull 一次,写入之前不会再次 pull。 **成员操作:** diff --git a/src/__tests__/e2e/dry-run-git-previews-900.test.ts b/src/__tests__/e2e/dry-run-git-previews-900.test.ts index 785286211..a5794bae6 100644 --- a/src/__tests__/e2e/dry-run-git-previews-900.test.ts +++ b/src/__tests__/e2e/dry-run-git-previews-900.test.ts @@ -328,3 +328,32 @@ describe.each([ expect(result.output).toContain('Dry run — no changes made'); }); }); + + +describe('unreachable origin', () => { + it('remove refuses with exit 1 and leaves the checkout and state unchanged', async () => { + const fixture = setUpClone(); + git(['remote', 'set-url', 'origin', path.join(fixture.home, 'missing-origin.git')], fixture.checkout); + const before = snapshot(fixture); + + const result = await dryRun(fixture, ['remove', 'rules', 'doomed']); + + expect(result.code, result.output).toBe(1); + expect(result.output).toContain('The team repo could not be refreshed'); + expect(result.output).toContain('names cannot be resolved against the current default branch. Nothing was removed.'); + expect(result.output).toContain('Fix the pull (run `teamai pull` to see why) and retry.'); + expect(result.output).not.toContain('Will remove'); + expect(snapshot(fixture)).toEqual(before); + }); + + it('roles and projects warn and preview the last fetched copy without writing', async () => { + const fixture = setUpClone(); + git(['remote', 'set-url', 'origin', path.join(fixture.home, 'missing-origin.git')], fixture.checkout); + for (const args of [['roles', 'add', 'x', '--namespaces', 'x'], ['projects', 'add', 'beta', '--namespaces', 'beta']]) { + const result = await dryRun(fixture, args); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain('previewing against the copy fetched last.'); + expect(result.output).toContain('[dry-run] Would add'); + } + }); +}); diff --git a/src/__tests__/roles-init-refresh.test.ts b/src/__tests__/roles-init-refresh.test.ts new file mode 100644 index 000000000..9af88526e --- /dev/null +++ b/src/__tests__/roles-init-refresh.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + autoDetectInit: vi.fn(), pullRepo: vi.fn(), pushRepoBranch: vi.fn(), + checkoutMaster: vi.fn(), generateBranchName: vi.fn(() => 'init-roles'), + askQuestion: vi.fn(), askConfirmation: vi.fn(), pathExists: vi.fn(), + saveRolesManifest: vi.fn(), createPrWithFallback: vi.fn(), +})); +vi.mock('../config.js', () => ({ autoDetectInit: mocks.autoDetectInit })); +vi.mock('../utils/git.js', () => mocks); +vi.mock('../utils/prompt.js', () => mocks); +vi.mock('../utils/fs.js', () => ({ pathExists: mocks.pathExists })); +vi.mock('../roles.js', () => ({ saveRolesManifest: mocks.saveRolesManifest })); +vi.mock('../push.js', () => ({ createPrWithFallback: mocks.createPrWithFallback })); +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn() }, + spinner: () => ({ start: () => ({ succeed: vi.fn(), warn: vi.fn() }) }), +})); + +import { rolesInit } from '../roles-cmd.js'; + +describe('roles init refresh ordering', () => { + beforeEach(() => vi.resetAllMocks()); + + it('does not retrieve and overwrite a manifest added upstream during the questions', async () => { + let checkedOutManifest: unknown; + let pulls = 0; + mocks.autoDetectInit.mockResolvedValue({ + localConfig: { repo: { localPath: '/team-repo' }, username: 'admin' }, teamConfig: {}, + }); + mocks.pullRepo.mockImplementation(async () => { + if (++pulls === 2) checkedOutManifest = { roles: [{ id: 'other-admin' }] }; + }); + mocks.pathExists.mockImplementation(async () => checkedOutManifest !== undefined); + mocks.askQuestion.mockResolvedValueOnce('new-role').mockResolvedValueOnce('').mockResolvedValueOnce('common'); + mocks.askConfirmation.mockResolvedValue(false); + const overwritten: unknown[] = []; + mocks.saveRolesManifest.mockImplementation(async (_path, manifest) => { + if (checkedOutManifest) overwritten.push(checkedOutManifest); + checkedOutManifest = manifest; + }); + mocks.pushRepoBranch.mockResolvedValue(false); + + await rolesInit({}); + + expect(mocks.saveRolesManifest).toHaveBeenCalled(); + expect(overwritten).toEqual([]); + expect(pulls).toBe(1); + }); +}); diff --git a/src/manifest-edit.ts b/src/manifest-edit.ts index a95ee20c2..f74562b99 100644 --- a/src/manifest-edit.ts +++ b/src/manifest-edit.ts @@ -29,17 +29,22 @@ export async function runManifestEdit( localConfig: LocalConfig, label: string, fn: (repoPath: string, editConfig: LocalConfig) => Promise, - options: { dryRun?: boolean } = {}, + options: { dryRun?: boolean; skipPull?: boolean } = {}, ): Promise { if (!options.dryRun && localConfig.repo.kind !== 'self') { - await pullLatest(localConfig.repo.localPath); + if (!options.skipPull) await pullLatest(localConfig.repo.localPath); await fn(localConfig.repo.localPath, localConfig); return; } const { withKnowledgeWorktree, withDefaultBranchPreview, EmptyRepoError } = await import('./utils/reports-branch.js'); - const withCheckout = options.dryRun ? withDefaultBranchPreview : withKnowledgeWorktree; + const body = (checkoutConfig: LocalConfig) => fn(checkoutConfig.repo.localPath, checkoutConfig); try { - await withCheckout(localConfig, (checkoutConfig) => fn(checkoutConfig.repo.localPath, checkoutConfig)); + if (options.dryRun) { + // Real manifest edits also warn and proceed when their pull fails. + await withDefaultBranchPreview(localConfig, body, { allowStale: true }); + } else { + await withKnowledgeWorktree(localConfig, body); + } } catch (e) { if (e instanceof EmptyRepoError) { log.error(e.message); diff --git a/src/remove.ts b/src/remove.ts index fe2e16437..1c8069cd7 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -43,12 +43,18 @@ export async function remove( // throwaway checkout of origin/ instead, in either mode: the pull // below can reset a clone with unpushed commits (#900). if (options.dryRun || localConfig.repo.kind === 'self') { - const { withKnowledgeWorktree, withDefaultBranchPreview, EmptyRepoError } = await import('./utils/reports-branch.js'); - const withCheckout = options.dryRun ? withDefaultBranchPreview : withKnowledgeWorktree; + const { withKnowledgeWorktree, withDefaultBranchPreview, EmptyRepoError, PreviewFetchError } = await import('./utils/reports-branch.js'); + const body = (checkoutConfig: LocalConfig) => removeCore(type, names, options, checkoutConfig, teamConfig); try { - await withCheckout(localConfig, (checkoutConfig) => removeCore(type, names, options, checkoutConfig, teamConfig)); + if (options.dryRun) { + await withDefaultBranchPreview(localConfig, body, { allowStale: localConfig.repo.kind === 'self' }); + } else { + await withKnowledgeWorktree(localConfig, body); + } } catch (e) { - if (e instanceof EmptyRepoError) { + if (e instanceof PreviewFetchError) { + refuseUnrefreshedRemoval(names, e); + } else if (e instanceof EmptyRepoError) { log.error(e.message); } else { log.error(`Remove failed: ${(e as Error).message}`); @@ -60,6 +66,15 @@ export async function remove( await removeCore(type, names, options, localConfig, teamConfig); } +function refuseUnrefreshedRemoval(names: string[], error: unknown): void { + log.error( + `The team repo could not be refreshed (${(error as Error).message}), so what "${names.join(', ')}" ` + + 'names cannot be resolved against the current default branch. Nothing was removed. ' + + 'Fix the pull (run `teamai pull` to see why) and retry.', + ); + process.exitCode = 1; +} + async function removeCore( type: string, names: string[], @@ -77,12 +92,7 @@ async function removeCore( try { await pullRepo(localConfig.repo.localPath); } catch (e) { - log.error( - `The team repo could not be refreshed (${(e as Error).message}), so what "${names.join(', ')}" ` - + 'names cannot be resolved against the current default branch. Nothing was removed. ' - + 'Fix the pull (run `teamai pull` to see why) and retry.', - ); - process.exitCode = 1; + refuseUnrefreshedRemoval(names, e); return; } } diff --git a/src/roles-cmd.ts b/src/roles-cmd.ts index 8c243ba88..99c432936 100644 --- a/src/roles-cmd.ts +++ b/src/roles-cmd.ts @@ -135,7 +135,7 @@ export async function rolesInit(options: GlobalOptions): Promise { commitMsg, prDescription: `Initialize roles manifest:\n${roles.map((r) => `- ${r.id} (namespaces: ${r.resources.skills.join(', ')})`).join('\n')}`, }); - }); + }, { skipPull: true }); // Keep the checkout checked before the interactive questions. } // ─── roles list ───────────────────────────────────────── diff --git a/src/utils/reports-branch.ts b/src/utils/reports-branch.ts index db0883fb7..df96cddd5 100644 --- a/src/utils/reports-branch.ts +++ b/src/utils/reports-branch.ts @@ -237,6 +237,14 @@ export async function withKnowledgeWorktree( } } +/** A failed refresh, distinct from errors while reading the preview checkout. */ +export class PreviewFetchError extends Error { + constructor(cause: unknown) { + super((cause as Error).message, { cause }); + this.name = 'PreviewFetchError'; + } +} + /** * Run `fn` against a throwaway checkout of origin/: the tree a real * `remove` or manifest edit reads after its pull (or, in single-repo mode, in @@ -251,6 +259,7 @@ export async function withKnowledgeWorktree( export async function withDefaultBranchPreview( localConfig: LocalConfig, fn: (previewConfig: LocalConfig) => Promise, + options: { allowStale?: boolean } = {}, ): Promise { const selfMode = localConfig.repo.kind === 'self'; const repoRoot = selfMode ? getBusinessRoot(localConfig) : localConfig.repo.localPath; @@ -259,6 +268,7 @@ export async function withDefaultBranchPreview( try { await git.fetch(['origin', defaultBranch]); } catch (e) { + if (!options.allowStale) throw new PreviewFetchError(e); log.warn(`Could not fetch origin/${defaultBranch} (${(e as Error).message}); previewing against the copy fetched last.`); } // origin/ may not exist locally (fresh repo); fall back to HEAD, as the worktree does. From bd3a418a36f562f620d04a3eed41f1d139f0cccd Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Sun, 4 Oct 2026 11:57:37 +0200 Subject: [PATCH 5/8] fix(dry-run): refuse dirty clones and preview cached import branch (#900) --- docs/usage-guide.md | 6 +- docs/usage-guide.zh-CN.md | 6 +- skill-data/setup/references/manage-admin.md | 4 ++ .../e2e/dry-run-git-previews-900.test.ts | 36 +++++++++++ .../e2e/import-repo-dry-run-900.test.ts | 64 +++++++++++++++++++ src/__tests__/manifest-edit.test.ts | 3 +- src/clone.ts | 35 +++++++--- src/import-repo.ts | 21 ++++-- src/manifest-edit.ts | 7 +- src/remove.ts | 7 +- src/utils/reports-branch.ts | 13 ++++ 11 files changed, 176 insertions(+), 26 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 78b174da0..866ffe691 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -873,7 +873,7 @@ Choose namespace [1-3] (default: 1 = common): - When several namespaces could take a new resource and there is no terminal to ask on (CI, a hook, `TEAMAI_NONINTERACTIVE`), push stops with exit 2, lists them, and asks for `--role ` - `--role`/`--project` places new resources only. An edit of a shared-root rule or agent stays at the shared root, and push says so - A placed resource stays maintainable from the machine that published it. While its PR is open, the open-PR record routes a later edit of the author's own copy back to that PR; once the file is on the default branch, `state.json` records where push put it, so the edit goes back to the same file, and an agent published into a namespace this directory has not activated is still editable rather than skipped as having no active source -- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, or this machine's placement records cannot be updated and saved, `remove` stops with exit 1 and removes nothing, because either can resolve the name to the wrong files. `--dry-run` only fetches: it resolves the names against origin's default branch, as the real run does after its pull, and saves no records. In clone mode, a failed fetch stops the preview with the same refusal and exit 1 as a real removal. +- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, or this machine's placement records cannot be updated and saved, `remove` stops with exit 1 and removes nothing, because either can resolve the name to the wrong files. `--dry-run` only fetches: it resolves the names against origin's default branch, as the real run does after its pull, and saves no records. In clone mode, a failed fetch stops the preview with the same refusal and exit 1 as a real removal. A clone with uncommitted changes is refused with exit 1; commit or stash them before previewing. Dirty business files do not block self-mode previews. - A local agent is an edit of the team agent it was delivered from: one in an active namespace first, then one this machine placed, then the shared-root agent either of them replaces. Only when none exists does `--role`/`--project` decide, and the agent is new in that namespace; if that namespace already holds an agent of that name, the agent is skipped rather than written over it, as a rule would be. Two active agents of one name stay ambiguous and are skipped, flag or not. The same agent name may exist in several namespaces, so a copy in an inactive one you did not name never blocks publishing yours. A placed agent that changed on the team since this checkout last synced it is held, because agents have no pre-push sync. Pull keeps your changed copy, so save your edit, delete the copy, run `teamai pull --force`, reapply the edit and push again. In single-repo mode, a root copy under `.teamai/` that matches an older version of the file it was placed at is held too: nothing refreshes it, so it is an old copy rather than an edit - A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` - An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. It replaces a shared-root agent of the same name, as an active namespace's agent would. An active namespace holding that name wins: that agent is the one deployed here @@ -936,7 +936,7 @@ teamai roles remove devops teamai roles add test --namespaces common,test --dry-run ``` -The `--namespaces` list is applied to `knowledge`, `skills` and `agents` alike. The commands above automatically push a branch and create an MR; the change takes effect team-wide once merged. With `--dry-run`, `teamai roles add/update/remove` and `teamai projects add/update/remove` fetch and read the manifest on origin's default branch; they do not pull the team repo or, in single-repo mode, create a worktree, so commits you have not pushed stay. If fetching fails, these manifest previews warn and use the last fetched copy, matching a real edit's warn-and-proceed policy. In clone mode, `roles init` pulls once before checking for an existing manifest and asking questions. It does not pull again before writing. +The `--namespaces` list is applied to `knowledge`, `skills` and `agents` alike. The commands above automatically push a branch and create an MR; the change takes effect team-wide once merged. With `--dry-run`, `teamai roles add/update/remove` and `teamai projects add/update/remove` fetch and read the manifest on origin's default branch; they do not pull the team repo or, in single-repo mode, create a worktree, so commits you have not pushed stay. If fetching fails, these manifest previews warn and use the last fetched copy, matching a real edit's warn-and-proceed policy. For these commands, clone previews refuse uncommitted changes with exit 1 and ask you to commit or stash them, since a real pull can retain local manifest edits. Dirty business files do not block self-mode previews. In clone mode, `roles init` pulls once before checking for an existing manifest and asking questions. It does not pull again before writing. **Member operations:** @@ -1922,7 +1922,7 @@ teamai import --from-repo https://github.com/org/repo --skip-enrich If core graph extraction or writing fails, the import reports an error without marking the commit as synced. The next incremental run retries that commit. -With `--dry-run`, `--from-repo` and `--from-repo-list` read each repo's head with `git ls-remote`, print `Would import / at ` and whether the local cache is current, and stop there: nothing is cloned or fetched into the cache, the import lock is not taken, and no AI step runs. +With `--dry-run`, `--from-repo` and `--from-repo-list` read each repo's target commit with `git ls-remote`, print `Would import / at ` and whether the local cache is current, and stop there: nothing is cloned or fetched into the cache, the import lock is not taken, and no AI step runs. With `--incremental` and a cache containing `LAST_SYNC`, the preview queries that cache's current branch at its configured origin, matching the real fetch/reset. Full-clone previews, including a missing cache or `LAST_SYNC`, follow remote HEAD. If the cached-branch query fails, the preview warns and previews the full-clone fallback. `--from-mr` publishes its learning the way `teamai contribute` does, on the `teamai-learnings` branch: under `learnings//` when exactly one active project declares a learnings namespace, otherwise at the shared `learnings/` root. If that fails, the learning stays queued on this machine and the next `teamai pull` publishes it; when a learnings checkout teamai refuses stopped it, no pull can until you deal with that checkout as the message says. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index df6449dd6..3fad3da38 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -779,7 +779,7 @@ Choose namespace [1-3] (default: 1 = common): - 当有多个 namespace 可接收新资源、且没有可供询问的终端(CI、hook、`TEAMAI_NONINTERACTIVE`)时,push 会以退出码 2 停止,列出这些 namespace,并要求使用 `--role ` - `--role`/`--project` 只放置新资源。对共享根目录 rule 或 agent 的修改仍留在共享根目录,push 会给出提示 - 已落点的资源在发布它的机器上仍可维护:PR 未合并期间,待评审 PR 记录会把作者对自己副本的修改带回该 PR;文件进入默认分支后,`state.json` 会记录 push 的落点,因此修改仍会写回同一个文件;即使 agent 落在本目录未激活的 namespace,也不会被当作“无活跃源”跳过 -- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,或本机的落点记录无法更新并保存,`remove` 会以退出码 1 停止且不删除任何内容,因为两者都可能把名字解析到错误的文件。`--dry-run` 只执行 fetch:像真实运行在 pull 之后那样,按 origin 默认分支解析名字,且不保存任何落点记录。克隆模式下 fetch 失败时,预览会使用与真实删除相同的拒绝消息,并以退出码 1 停止。 +- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,或本机的落点记录无法更新并保存,`remove` 会以退出码 1 停止且不删除任何内容,因为两者都可能把名字解析到错误的文件。`--dry-run` 只执行 fetch:像真实运行在 pull 之后那样,按 origin 默认分支解析名字,且不保存任何落点记录。克隆模式下 fetch 失败时,预览会使用与真实删除相同的拒绝消息,并以退出码 1 停止。克隆存在未提交更改时,预览也会以退出码 1 拒绝运行,请先 commit 或 stash。业务文件的未提交更改不会阻止单仓模式预览。 - 本地 agent 被视为其来源团队 agent 的编辑:优先是活跃 namespace 中的 agent,其次是本机放置的 agent,最后是被二者替换的共享根目录 agent。只有三者都不存在时,才由 `--role`/`--project` 决定,此时该 agent 在该 namespace 中是新的;若该 namespace 已有同名 agent,则跳过该 agent 而不是覆盖它,与 rule 的处理一致。两个活跃的同名 agent 无论是否指定参数都视为有歧义并跳过。同名 agent 允许存在于多个 namespace,因此你未指定的非活跃 namespace 中的同名副本不会阻止你发布。本机放置的 agent 若在当前检出上次同步后被团队修改,会暂缓推送,因为 agents 没有推送前同步。pull 会保留你修改过的副本,因此请先另存你的修改,删除该副本,执行 `teamai pull --force`,重新应用修改后再 push。单仓库模式下,`.teamai/` 中的根目录副本若与其落点文件的某个旧版本相同,也会暂缓推送:没有任何操作会刷新它,因此它是旧副本而不是编辑 - 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace - 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;它会像活跃 namespace 中的 agent 一样替换共享根目录的同名 agent。若已激活的 namespace 中已有同名 agent,则以它为准 @@ -837,7 +837,7 @@ teamai roles remove devops teamai roles add test --namespaces common,test --dry-run ``` -`--namespaces` 列表会同时应用到 `knowledge`、`skills` 与 `agents`。以上命令会自动 push 分支并创建 MR,合并后对全团队生效。加 `--dry-run` 时,`teamai roles add/update/remove` 与 `teamai projects add/update/remove` 只 fetch 并读取 origin 默认分支上的 manifest,不会 pull 团队仓库,单仓模式下也不会创建 worktree,因此尚未推送的提交会保留。若 fetch 失败,这些 manifest 预览会警告并使用上次获取的副本,与真实编辑在 pull 失败后警告并继续的策略一致。克隆模式下,`roles init` 只在检查已有 manifest 和交互提问之前 pull 一次,写入之前不会再次 pull。 +`--namespaces` 列表会同时应用到 `knowledge`、`skills` 与 `agents`。以上命令会自动 push 分支并创建 MR,合并后对全团队生效。加 `--dry-run` 时,`teamai roles add/update/remove` 与 `teamai projects add/update/remove` 只 fetch 并读取 origin 默认分支上的 manifest,不会 pull 团队仓库,单仓模式下也不会创建 worktree,因此尚未推送的提交会保留。若 fetch 失败,这些 manifest 预览会警告并使用上次获取的副本,与真实编辑在 pull 失败后警告并继续的策略一致。克隆预览遇到未提交更改时会以退出码 1 拒绝运行,并提示先 commit 或 stash,因为真实 pull 可能保留本地 manifest 编辑。业务文件的未提交更改不会阻止单仓模式预览。克隆模式下,`roles init` 只在检查已有 manifest 和交互提问之前 pull 一次,写入之前不会再次 pull。 **成员操作:** @@ -1767,7 +1767,7 @@ teamai import --from-repo https://github.com/org/repo --skip-enrich 如果核心知识图谱提取或写入失败,导入会报错,且不会将该提交标记为已同步。下次增量导入会重试该提交。 -加 `--dry-run` 时,`--from-repo` 与 `--from-repo-list` 用 `git ls-remote` 读取每个仓库的最新提交,打印 `Would import / at ` 以及本地缓存是否最新,然后停止:不会 clone 或 fetch 到缓存,不会获取导入锁,也不会运行任何 AI 步骤。 +加 `--dry-run` 时,`--from-repo` 与 `--from-repo-list` 用 `git ls-remote` 读取每个仓库的目标提交,打印 `Would import / at ` 以及本地缓存是否最新,然后停止:不会 clone 或 fetch 到缓存,不会获取导入锁,也不会运行任何 AI 步骤。使用 `--incremental` 且缓存含 `LAST_SYNC` 时,预览会查询该缓存配置的 origin 上当前分支的提交,与真实 fetch/reset 一致。完整克隆预览(包括缺少缓存或 `LAST_SYNC`)跟随远端 HEAD。若缓存分支查询失败,预览会警告并预览完整克隆回退。 `--from-mr` 与 `teamai contribute` 一样,把提取的经验发布到 `teamai-learnings` 分支:恰好一个激活项目声明了 learnings namespace 时放在 `learnings//` 下,否则放在共享的 `learnings/` 根目录。发布失败时,经验留在本机队列中,下次 `teamai pull` 会发布它;若阻止发布的是 teamai 拒绝使用的 learnings 检出,则在你按提示处理该检出之前,任何 pull 都无法发布它。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index bb614c808..6e1460371 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -140,6 +140,10 @@ refuse `--dry-run` before pulling the team repo. After `projects remove`, keep the project's content in the team repo until members have pulled: that is what lets their next pull clean up the copies they deployed. +`roles` and `projects` add/update/remove previews, and resource removal +previews, refuse a team clone with uncommitted changes. Commit or stash them before retrying. Single-repo +previews still work with dirty business files because they read origin. + Every namespace that names a directory — `knowledge`, `skills`, `agents`, `env`, `hooks`, `mcp`, `models` and `docs` in either manifest, and `learnings` in `projects.yaml` (a role's `learnings:` is ignored and unchecked) — must be a single path segment: no `/`, `\`, `:` or control character, no trailing diff --git a/src/__tests__/e2e/dry-run-git-previews-900.test.ts b/src/__tests__/e2e/dry-run-git-previews-900.test.ts index a5794bae6..957668432 100644 --- a/src/__tests__/e2e/dry-run-git-previews-900.test.ts +++ b/src/__tests__/e2e/dry-run-git-previews-900.test.ts @@ -357,3 +357,39 @@ describe('unreachable origin', () => { } }); }); + + +describe('dirty preview checkouts', () => { + it('refuses dirty clone previews where the real pull preserves the local manifest', async () => { + const fixture = setUpClone(); + git(['fetch', '-q', 'origin'], fixture.checkout); + git(['reset', '--hard', 'origin/main'], fixture.checkout); + const rolesPath = path.join(fixture.checkout, 'manifest', 'roles.yaml'); + fs.appendFileSync(rolesPath, ' - id: x\n description: local edit\n resources:\n knowledge: [x]\n skills: [x]\n agents: [x]\n'); + const dirtyManifest = fs.readFileSync(rolesPath, 'utf8'); + const before = snapshot(fixture); + + const real = await runCLI(['roles', 'add', 'x', '--namespaces', 'x'], fixture.cwd, fixture.home); + expect(real.output).toContain('Role "x" already exists'); + expect(snapshot(fixture)).toEqual(before); + + for (const args of [['roles', 'add', 'x', '--namespaces', 'x'], ['projects', 'add', 'beta', '--namespaces', 'beta'], ['remove', 'rules', 'doomed']]) { + const result = await dryRun(fixture, args); + expect(result.code, result.output).toBe(1); + expect(result.output).toContain('Cannot preview a team repo with uncommitted changes'); + expect(result.output).toContain('Commit or stash the changes'); + expect(result.output).not.toContain('[dry-run] Would add'); + expect(result.output).not.toContain('Will remove'); + expect(fs.readFileSync(rolesPath, 'utf8')).toBe(dirtyManifest); + } + }); + + it('still previews self-mode knowledge while business files are dirty', async () => { + const fixture = setUpSelf(); + fs.appendFileSync(path.join(fixture.checkout, 'app.txt'), 'local business edit\n'); + const result = await dryRun(fixture, ['roles', 'add', 'x', '--namespaces', 'x']); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain('[dry-run] Would add role "x"'); + expect(fs.readFileSync(path.join(fixture.checkout, 'app.txt'), 'utf8')).toContain('local business edit'); + }); +}); diff --git a/src/__tests__/e2e/import-repo-dry-run-900.test.ts b/src/__tests__/e2e/import-repo-dry-run-900.test.ts index b59faf8c9..83eb871d9 100644 --- a/src/__tests__/e2e/import-repo-dry-run-900.test.ts +++ b/src/__tests__/e2e/import-repo-dry-run-900.test.ts @@ -166,6 +166,70 @@ describe('import --from-repo --dry-run (#900 C3)', () => { expect(result.output).toContain(`cache would be refreshed from 00000000 to ${head.slice(0, 8)}`); }); + function changeDefaultAfterCachingMaster(): string { + git(['branch', 'master', head], remote); + git(['symbolic-ref', 'HEAD', 'refs/heads/master'], remote); + seedCache(head); + const seed = path.join(sandbox, 'seed'); + fs.appendFileSync(path.join(seed, 'index.ts'), 'export const newer = 2;\n'); + git(['add', '-A'], seed); + git(['commit', '-q', '-m', 'main moves on'], seed); + git(['push', '-q', remote, 'main'], seed); + git(['symbolic-ref', 'HEAD', 'refs/heads/main'], remote); + return git(['rev-parse', 'main'], remote).trim(); + } + + it('incremental preview follows cached master after remote HEAD changes to main', async () => { + const main = changeDefaultAfterCachingMaster(); + expect(main).not.toBe(head); + expect(git(['branch', '--show-current'], cacheDir).trim()).toBe('master'); + const result = await dryRun(['import', '--from-repo', URL, '--incremental']); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain(`Would import acme/widget at ${head.slice(0, 8)}`); + expect(result.output).toContain('so an incremental run would skip it'); + expect(result.output).not.toContain(main.slice(0, 8)); + const real = await runCLI(['import', '--from-repo', URL, '--incremental']); + expect(real.code, real.output).toBe(0); + expect(real.output).toContain(`SHA unchanged (${head.slice(0, 8)})`); + expect(git(['rev-parse', 'HEAD'], cacheDir).trim()).toBe(head); + }); + + it('full-clone preview follows remote HEAD even when the cache is on master', async () => { + const main = changeDefaultAfterCachingMaster(); + const result = await dryRun(['import', '--from-repo', URL]); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain(`Would import acme/widget at ${main.slice(0, 8)}`); + }); + + it.each(['cache', 'LAST_SYNC'])('incremental preview follows remote HEAD without %s', async (missing) => { + const main = changeDefaultAfterCachingMaster(); + if (missing === 'cache') fs.rmSync(cacheDir, { recursive: true }); + else fs.rmSync(path.join(cacheDir, 'LAST_SYNC')); + const result = await dryRun(['import', '--from-repo', URL, '--incremental']); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain(`Would import acme/widget at ${main.slice(0, 8)}`); + expect(result.output).not.toContain('so an incremental run would skip it'); + }); + + it('previews the full-clone fallback when the cached origin is unreachable', async () => { + const main = changeDefaultAfterCachingMaster(); + git(['remote', 'set-url', 'origin', path.join(sandbox, 'missing-origin.git')], cacheDir); + const result = await dryRun(['import', '--from-repo', URL, '--incremental']); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain('preview refresh failed, previewing a full clone instead'); + expect(result.output).toContain(`Would import acme/widget at ${main.slice(0, 8)}`); + expect(result.output).not.toContain('so an incremental run would skip it'); + }); + + it('reads the cached origin HEAD branch for a detached incremental cache', async () => { + const main = changeDefaultAfterCachingMaster(); + git(['checkout', '-q', '--detach'], cacheDir); + const result = await dryRun(['import', '--from-repo', URL, '--incremental']); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain(`Would import acme/widget at ${head.slice(0, 8)}`); + expect(result.output).not.toContain(main.slice(0, 8)); + }); + it('previews every entry of --from-repo-list', async () => { const list = path.join(sandbox, 'repos.yaml'); fs.writeFileSync(list, `repos:\n - url: ${URL}\n`); diff --git a/src/__tests__/manifest-edit.test.ts b/src/__tests__/manifest-edit.test.ts index f0559d5e0..29726bfac 100644 --- a/src/__tests__/manifest-edit.test.ts +++ b/src/__tests__/manifest-edit.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const reportsMocks = vi.hoisted(() => { class EmptyRepoError extends Error {} - return { withKnowledgeWorktree: vi.fn(), withDefaultBranchPreview: vi.fn(), EmptyRepoError }; + class DirtyPreviewError extends Error {} + return { withKnowledgeWorktree: vi.fn(), withDefaultBranchPreview: vi.fn(), EmptyRepoError, DirtyPreviewError }; }); vi.mock('../utils/reports-branch.js', () => reportsMocks); diff --git a/src/clone.ts b/src/clone.ts index 7b993777f..c6502752a 100644 --- a/src/clone.ts +++ b/src/clone.ts @@ -354,24 +354,39 @@ export async function shallowFetch( /** * The commit the remote's default branch (HEAD) points at, read with * `git ls-remote` and the same auth {@link shallowClone} would use. Nothing is - * written locally, which is what a dry run needs. + * written locally, which is what a dry run needs. With `cachedRepoPath`, query + * the cached branch at its origin using shallowFetch's auth instead. */ export async function remoteHeadSha( url: string, provider: string, - opts?: Pick, + opts?: Pick & { cachedRepoPath?: string }, ): Promise { - const { cloneUrl, extraAuthHeader } = resolveCloneAuth(url, provider, { - forceSsh: opts?.forceSsh ?? false, - forceAnonymous: opts?.forceAnonymous ?? false, - }); - const args = extraAuthHeader ? ['-c', `http.extraHeader=${extraAuthHeader}`] : []; - args.push('ls-remote', cloneUrl, 'HEAD'); - const { stdout, stderr, code } = await runCommand('git', args, { timeoutMs: opts?.timeoutMs ?? 60_000 }); + const timeoutMs = opts?.timeoutMs ?? 60_000; + const cachedRepoPath = opts?.cachedRepoPath; + let ref = 'HEAD'; + let auth: { cloneUrl: string; extraAuthHeader?: string }; + if (cachedRepoPath) { + let branch = await gitCmd(['rev-parse', '--abbrev-ref', 'HEAD'], cachedRepoPath, timeoutMs); + if (branch === 'HEAD') { + branch = (await gitCmd(['symbolic-ref', 'refs/remotes/origin/HEAD'], cachedRepoPath, timeoutMs)) + .replace('refs/remotes/origin/', ''); + } + ref = `refs/heads/${branch}`; + auth = { cloneUrl: 'origin', extraAuthHeader: fetchAuthHeader(provider) ?? undefined }; + } else { + auth = resolveCloneAuth(url, provider, { + forceSsh: opts?.forceSsh ?? false, + forceAnonymous: opts?.forceAnonymous ?? false, + }); + } + const args = auth.extraAuthHeader ? ['-c', `http.extraHeader=${auth.extraAuthHeader}`] : []; + args.push('ls-remote', auth.cloneUrl, ref); + const { stdout, stderr, code } = await runCommand('git', args, { cwd: cachedRepoPath, timeoutMs }); if (code !== 0) { throw new Error(`git ls-remote failed (exit ${code}): ${redactToken(stderr.trim())}`); } const sha = stdout.trim().split(/\s+/)[0]; - if (!sha) throw new Error(`git ls-remote found no HEAD at ${sanitizeGitUrl(url)}`); + if (!sha) throw new Error(`git ls-remote found no ${ref} at ${cachedRepoPath ? 'the cached origin' : sanitizeGitUrl(url)}`); return sha; } diff --git a/src/import-repo.ts b/src/import-repo.ts index 223550e28..d63e1363c 100644 --- a/src/import-repo.ts +++ b/src/import-repo.ts @@ -182,7 +182,7 @@ export function detectCrossRepoEdges( * 5. Append AI narrative to teamwiki/evidence/code//overview.md * 6. Write LAST_SYNC * - * A dry run stops after step 1: it reads the remote head with `git ls-remote` + * A dry run stops after step 1: it reads the target remote commit with `git ls-remote` * and reports whether the cache is current. * * @throws Error on clone failure, scan failure, or IO failure @@ -212,22 +212,33 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise const lastSync = await readLastSync(cacheDir); const cacheExists = await fs.pathExists(path.join(cacheDir, '.git')); + const useIncremental = incremental && cacheExists && lastSync !== null; - // A preview asks the remote for its head and stops: the clone replaces the + // A preview asks for the target remote commit and stops: the clone replaces the // cache (or fetches and resets it), and the lock and LLM scan follow (#900). if (dryRun) { - const head = await remoteHeadSha(url, providerName, { forceSsh, forceAnonymous }); + let head: string; + let previewIncremental = useIncremental; + try { + head = await remoteHeadSha(url, providerName, { + forceSsh, forceAnonymous, cachedRepoPath: useIncremental ? cacheDir : undefined, + }); + } catch (error) { + if (!useIncremental) throw error; + log.warn(`[incremental] preview refresh failed, previewing a full clone instead: ${(error as Error).message}`); + head = await remoteHeadSha(url, providerName, { forceSsh, forceAnonymous }); + previewIncremental = false; + } const cache = !cacheExists ? `not cached yet, a real run would clone it into ${cacheDir}` : lastSync?.sha === head - ? `cache is current at ${head.slice(0, 8)}${incremental ? ', so an incremental run would skip it' : ''}` + ? `cache is current at ${head.slice(0, 8)}${previewIncremental ? ', so an incremental run would skip it' : ''}` : `cache would be refreshed from ${lastSync ? lastSync.sha.slice(0, 8) : 'an unrecorded commit'} to ${head.slice(0, 8)}`; log.info(`[dry-run] Would import ${owner}/${repoName} at ${head.slice(0, 8)} into teamwiki/evidence/code/${slug}; ${cache}`); return; } await ensureCacheRoot(); - const useIncremental = incremental && cacheExists && lastSync !== null; let cloneSha: string; let cloneBranch: string; diff --git a/src/manifest-edit.ts b/src/manifest-edit.ts index f74562b99..dcd228d48 100644 --- a/src/manifest-edit.ts +++ b/src/manifest-edit.ts @@ -36,7 +36,7 @@ export async function runManifestEdit( await fn(localConfig.repo.localPath, localConfig); return; } - const { withKnowledgeWorktree, withDefaultBranchPreview, EmptyRepoError } = await import('./utils/reports-branch.js'); + const { withKnowledgeWorktree, withDefaultBranchPreview, EmptyRepoError, DirtyPreviewError } = await import('./utils/reports-branch.js'); const body = (checkoutConfig: LocalConfig) => fn(checkoutConfig.repo.localPath, checkoutConfig); try { if (options.dryRun) { @@ -46,7 +46,10 @@ export async function runManifestEdit( await withKnowledgeWorktree(localConfig, body); } } catch (e) { - if (e instanceof EmptyRepoError) { + if (e instanceof DirtyPreviewError) { + log.error(e.message); + process.exitCode = 1; + } else if (e instanceof EmptyRepoError) { log.error(e.message); } else { log.error(`${label} update failed: ${(e as Error).message}`); diff --git a/src/remove.ts b/src/remove.ts index 1c8069cd7..5efaaa603 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -43,7 +43,7 @@ export async function remove( // throwaway checkout of origin/ instead, in either mode: the pull // below can reset a clone with unpushed commits (#900). if (options.dryRun || localConfig.repo.kind === 'self') { - const { withKnowledgeWorktree, withDefaultBranchPreview, EmptyRepoError, PreviewFetchError } = await import('./utils/reports-branch.js'); + const { withKnowledgeWorktree, withDefaultBranchPreview, EmptyRepoError, PreviewFetchError, DirtyPreviewError } = await import('./utils/reports-branch.js'); const body = (checkoutConfig: LocalConfig) => removeCore(type, names, options, checkoutConfig, teamConfig); try { if (options.dryRun) { @@ -52,7 +52,10 @@ export async function remove( await withKnowledgeWorktree(localConfig, body); } } catch (e) { - if (e instanceof PreviewFetchError) { + if (e instanceof DirtyPreviewError) { + log.error(e.message); + process.exitCode = 1; + } else if (e instanceof PreviewFetchError) { refuseUnrefreshedRemoval(names, e); } else if (e instanceof EmptyRepoError) { log.error(e.message); diff --git a/src/utils/reports-branch.ts b/src/utils/reports-branch.ts index df96cddd5..3fe3cd64f 100644 --- a/src/utils/reports-branch.ts +++ b/src/utils/reports-branch.ts @@ -245,6 +245,14 @@ export class PreviewFetchError extends Error { } } +/** A clone's dirty files may survive a real pull but are absent from origin. */ +export class DirtyPreviewError extends Error { + constructor(repoRoot: string) { + super(`Cannot preview a team repo with uncommitted changes at ${repoRoot}. Commit or stash the changes, then retry --dry-run. Nothing was changed.`); + this.name = 'DirtyPreviewError'; + } +} + /** * Run `fn` against a throwaway checkout of origin/: the tree a real * `remove` or manifest edit reads after its pull (or, in single-repo mode, in @@ -264,6 +272,11 @@ export async function withDefaultBranchPreview( const selfMode = localConfig.repo.kind === 'self'; const repoRoot = selfMode ? getBusinessRoot(localConfig) : localConfig.repo.localPath; const git = createGit(repoRoot); + // A successful clone pull may preserve dirty manifests/resources. Do not + // silently replace that view with origin; self mode really reads a worktree. + if (!selfMode && (await git.raw(['--no-optional-locks', 'status', '--porcelain', '--untracked-files=all'])).trim()) { + throw new DirtyPreviewError(repoRoot); + } const defaultBranch = await getDefaultBranch(repoRoot); try { await git.fetch(['origin', defaultBranch]); From 7ffe2732d75ad75a4c444835ddefb0667268f717 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Sun, 4 Oct 2026 16:27:20 +0200 Subject: [PATCH 6/8] fix(dry-run): match post-pull branches and restore preview guards (#900) --- CHANGELOG.md | 2 +- docs/usage-guide.md | 12 ++-- docs/usage-guide.zh-CN.md | 12 ++-- skill-data/setup/references/manage-admin.md | 11 +-- src/__tests__/dry-run-guard.test.ts | 16 ++++- .../e2e/dry-run-git-previews-900.test.ts | 67 ++++++++++++++++++- src/__tests__/e2e/dry-run-refusal.test.ts | 12 ++-- .../e2e/import-repo-dry-run-900.test.ts | 17 ++++- src/dry-run-guard.ts | 20 +++--- src/import-repo.ts | 7 +- src/manifest-edit.ts | 4 +- src/remove.ts | 4 +- src/roles-cmd.ts | 21 +++--- src/utils/reports-branch.ts | 46 ++++++++++--- 14 files changed, 185 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39b4ff4e4..d687886ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,7 +65,7 @@ All notable changes to this project will be documented in this file. See [standa - `teamai init` prints the Codex trust reminder when it installs hooks for the public Codex, and the reminder says that until the hooks are trusted a project's team rules do not reach Codex (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - When no team rule reaches a member any more, pull also removes a copy an older release wrote before the tool got its own rules format (a verbatim Kiro, Qoder, CodeBuddy, WorkBuddy or Oh My Pi copy, a Cursor render in JoyCode), not only one in the current format (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - `teamai import --from-repo` and `--from-repo-list` with `--dry-run` no longer clone, fetch or reset the repo cache, take the import lock or run the AI scan. They read each repo's head with `git ls-remote` and print `Would import / at ` with whether the cache is current (for [#900](https://github.com/Tencent/teamai-cli/issues/900)). -- `--dry-run` no longer discards commits you have not pushed. `teamai remove`, `teamai roles init/add/remove/update` and `teamai projects add/update/remove` pulled the team repo before their dry-run check, and a pull that cannot fast-forward resets the clone to origin; `remove` also saved this machine's placement records, and in single-repo mode both created the knowledge worktree. A dry run no longer pulls: `remove`, `roles add/remove/update` and `projects add/update/remove` fetch and read origin's default branch from a temporary checkout outside the repo, and save nothing (for [#900](https://github.com/Tencent/teamai-cli/issues/900)). +- `--dry-run` no longer discards commits you have not pushed. `teamai remove`, `teamai roles init/add/remove/update` and `teamai projects add/update/remove` pulled the team repo before their dry-run check, and a pull that cannot fast-forward resets the clone to origin; `remove` also saved this machine's placement records, and in single-repo mode both created the knowledge worktree. A dry run no longer pulls: `remove`, `roles init/add/remove/update` and `projects add/update/remove` fetch and read the clone's post-pull branch contents (origin's default branch in self mode) from a temporary checkout outside the repo, and save nothing (for [#900](https://github.com/Tencent/teamai-cli/issues/900)). - Team rules reach Codex, `codex-internal` and `tcodex`. Pull used to copy them to `.codex/rules/.md`, which Codex does not read, and `teamai doctor` reported them delivered. In user scope they now go into a team-rules block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`), beside the culture, shared-instructions and recall blocks. In a project the session-start hook adds the project's rules and those blocks to each session, and to a spawned subagent through a new `SubagentStart` entry, with `additionalContextLimit: 0` so Codex keeps them whole; pull leaves the project `AGENTS.md`, which other tools read too, unchanged. Pull removes the `.md` copies earlier pulls left in the Codex rules directory at its recorded `toolRoots` location, including a publisher's bare local copy, keeping an edited one with a warning. Pull and uninstall keep a removed rule's legacy copy unless it matches its recorded delivery hash; a missing record proves nothing about local edits. A path-scoped rule is rendered without frontmatter after an `Applies to files matching: ` line, in Hermes' `SOUL.md` too. `teamai doctor` checks the user-scope block and, in a project, both hook entries' limit. The public Codex asks once to approve the changed teamai hooks (for [#938](https://github.com/Tencent/teamai-cli/issues/938)). - `teamai pull` names the skills it removes because they are no longer delivered here, in one line, instead of a `debug` line calling them excluded and a summary that says `No resources to sync`. Picking a role or project, or an admin adding `manifest/projects.yaml`, takes root skills away, since the root `skills/` is then the tag catalog; the line then says that `teamai tags subscribe ` brings one back. A namespace skill removed because its namespace is no longer active is named too. The usage guide and the multi-project design no longer say a member with no project still gets `common` or every root item (for [#911](https://github.com/Tencent/teamai-cli/issues/911)). - `teamai pull` keeps the `teamai tags subscribe ` recovery line when the skill directory it removes is byte-identical to an inactive namespace copy: that copy made the namespace cleanup phase remove the directory first, and the hint was lost, because pull inferred whether a root skill had left from which phase did the removing. The hint now follows the repo — it appears exactly when the team repo holds the removed skill at the root, the copy a tag delivers — so a namespace-only skill removed on deactivation is still named without the hint. The usage guide no longer says a member with no role gets no skills at all, in English or Chinese: root skills still arrive through a tag (review of [#917](https://github.com/Tencent/teamai-cli/pull/917)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 866ffe691..a339bc69a 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -374,7 +374,7 @@ teamai projects set hai-inference # Set active project(s) for this directory teamai projects set hai-inference --dry-run # Preview the selection without saving it teamai projects members hai-inference # Who is registered on a project -# Admin: edit manifest/projects.yaml and open a PR (--dry-run available after #971 lands and restores the guard classification) +# Admin: edit manifest/projects.yaml and open a PR teamai projects add checkout --namespaces common,checkout --name "Checkout" # The first add creates projects.yaml teamai projects update checkout --add-namespaces payments --remove-namespaces common teamai projects remove checkout @@ -721,7 +721,7 @@ teamai pull --dry-run # Dry run, no actual changes A command with no `--dry-run` preview, such as `teamai init`, `teamai hooks remove`, `teamai models add` / `configure` / `remove`, `teamai bind-project` or `teamai codebase --extract`, refuses the flag: it prints `teamai has no --dry-run preview, nothing was run` and exits 1. -Until #971 lands, `remove`, `roles init/add/remove/update`, `projects add/update/remove`, and `import --from-repo/--from-repo-list` also refuse `--dry-run`. Until #970 lands, `stats` and `recall ` refuse it. `digest`, `import --from-claude`, and `import --from-iwiki` have no safe preview and refuse it too. Previews for `import --from-org`, `--from-mr`, `--dir`, and `recall feedback` remain available. The later-merging PR must restore the guard classification for commands whose previews are fixed. +`remove`, `roles init/add/remove/update`, `projects add/update/remove`, and `import --from-repo/--from-repo-list` support `--dry-run`. Remote import sources take precedence over lower-priority iWiki or Claude flags. Until #970 lands, `stats` and `recall ` refuse it. `digest`, `import --from-claude`, and `import --from-iwiki` have no safe preview and refuse it too. Previews for `import --from-org`, `--from-mr`, `--dir`, and `recall feedback` remain available. The later-merging PR must restore the guard classification for commands whose previews are fixed. A manual `teamai pull` ends by running the `teamai doctor` checks and printing each one that failed, with its fix — including whether the skills it just reported syncing are readable on disk for every enabled tool. It prints nothing when they all pass, and the exit code is unchanged. The SessionStart hook path and `--dry-run` run no checks at all, so session startup stays as fast as before. Provider checks (`gh`/`gf` authentication) are left to `teamai doctor`: the pull just used the provider. @@ -873,7 +873,7 @@ Choose namespace [1-3] (default: 1 = common): - When several namespaces could take a new resource and there is no terminal to ask on (CI, a hook, `TEAMAI_NONINTERACTIVE`), push stops with exit 2, lists them, and asks for `--role ` - `--role`/`--project` places new resources only. An edit of a shared-root rule or agent stays at the shared root, and push says so - A placed resource stays maintainable from the machine that published it. While its PR is open, the open-PR record routes a later edit of the author's own copy back to that PR; once the file is on the default branch, `state.json` records where push put it, so the edit goes back to the same file, and an agent published into a namespace this directory has not activated is still editable rather than skipped as having no active source -- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, or this machine's placement records cannot be updated and saved, `remove` stops with exit 1 and removes nothing, because either can resolve the name to the wrong files. `--dry-run` only fetches: it resolves the names against origin's default branch, as the real run does after its pull, and saves no records. In clone mode, a failed fetch stops the preview with the same refusal and exit 1 as a real removal. A clone with uncommitted changes is refused with exit 1; commit or stash them before previewing. Dirty business files do not block self-mode previews. +- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, or this machine's placement records cannot be updated and saved, `remove` stops with exit 1 and removes nothing, because either can resolve the name to the wrong files. `--dry-run` only fetches: it resolves names against the contents a real pull would use on the clone's checked-out branch (or origin's default branch in self mode), and saves no records. In clone mode, a failed fetch stops the preview with the same refusal and exit 1 as a real removal. A clone with uncommitted changes is refused with exit 1; commit or stash them before previewing. Dirty business files do not block self-mode previews. - A local agent is an edit of the team agent it was delivered from: one in an active namespace first, then one this machine placed, then the shared-root agent either of them replaces. Only when none exists does `--role`/`--project` decide, and the agent is new in that namespace; if that namespace already holds an agent of that name, the agent is skipped rather than written over it, as a rule would be. Two active agents of one name stay ambiguous and are skipped, flag or not. The same agent name may exist in several namespaces, so a copy in an inactive one you did not name never blocks publishing yours. A placed agent that changed on the team since this checkout last synced it is held, because agents have no pre-push sync. Pull keeps your changed copy, so save your edit, delete the copy, run `teamai pull --force`, reapply the edit and push again. In single-repo mode, a root copy under `.teamai/` that matches an older version of the file it was placed at is held too: nothing refreshes it, so it is an old copy rather than an edit - A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` - An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. It replaces a shared-root agent of the same name, as an active namespace's agent would. An active namespace holding that name wins: that agent is the one deployed here @@ -932,11 +932,11 @@ teamai roles update hai --remove-namespaces legacy -d "New description" # Remove a role teamai roles remove devops -# Preview changes (after #971 lands and restores the guard classification) +# Preview changes teamai roles add test --namespaces common,test --dry-run ``` -The `--namespaces` list is applied to `knowledge`, `skills` and `agents` alike. The commands above automatically push a branch and create an MR; the change takes effect team-wide once merged. With `--dry-run`, `teamai roles add/update/remove` and `teamai projects add/update/remove` fetch and read the manifest on origin's default branch; they do not pull the team repo or, in single-repo mode, create a worktree, so commits you have not pushed stay. If fetching fails, these manifest previews warn and use the last fetched copy, matching a real edit's warn-and-proceed policy. For these commands, clone previews refuse uncommitted changes with exit 1 and ask you to commit or stash them, since a real pull can retain local manifest edits. Dirty business files do not block self-mode previews. In clone mode, `roles init` pulls once before checking for an existing manifest and asking questions. It does not pull again before writing. +The `--namespaces` list is applied to `knowledge`, `skills` and `agents` alike. The commands above automatically push a branch and create an MR; the change takes effect team-wide once merged. With `--dry-run`, `teamai roles init/add/update/remove` and `teamai projects add/update/remove` fetch and read the manifest a real pull would use on the clone's checked-out branch (or origin's default branch in single-repo mode); they do not pull the team repo or, in single-repo mode, create a worktree, so commits you have not pushed stay. If fetching fails, these manifest previews warn and use the unchanged clone checkout, or the last fetched default-branch copy in single-repo mode, matching a real edit's warn-and-proceed policy. For these commands, clone previews refuse uncommitted changes with exit 1 and ask you to commit or stash them, since a real pull can retain local manifest edits. Dirty business files do not block self-mode previews. A clean clone preview retains an ahead branch, advances a behind branch, and uses origin/current-branch after divergence, matching the real pull. `roles init --dry-run` checks for an existing manifest and asks for overwrite confirmation inside that temporary checkout. In clone mode, real `roles init` pulls once before checking for an existing manifest and asking questions. It does not pull again before writing. **Member operations:** @@ -1922,7 +1922,7 @@ teamai import --from-repo https://github.com/org/repo --skip-enrich If core graph extraction or writing fails, the import reports an error without marking the commit as synced. The next incremental run retries that commit. -With `--dry-run`, `--from-repo` and `--from-repo-list` read each repo's target commit with `git ls-remote`, print `Would import / at ` and whether the local cache is current, and stop there: nothing is cloned or fetched into the cache, the import lock is not taken, and no AI step runs. With `--incremental` and a cache containing `LAST_SYNC`, the preview queries that cache's current branch at its configured origin, matching the real fetch/reset. Full-clone previews, including a missing cache or `LAST_SYNC`, follow remote HEAD. If the cached-branch query fails, the preview warns and previews the full-clone fallback. +With `--dry-run`, `--from-repo` and `--from-repo-list` read each repo's target commit with `git ls-remote`, print `Would import / at ` and whether the local cache is current, and stop there: nothing is cloned or fetched into the cache, the import lock is not taken, and no AI step runs. With `--incremental` and a cache containing `LAST_SYNC`, the preview queries that cache's current branch at its configured origin, matching the real fetch/reset. Full-clone previews, including a missing cache or `LAST_SYNC`, follow remote HEAD. If the cached-branch query fails, the preview warns and previews the full-clone fallback. With `--output`, the preview reports the same `teamwiki/evidence/code/` destination beside the output file as a real import. `--from-mr` publishes its learning the way `teamai contribute` does, on the `teamai-learnings` branch: under `learnings//` when exactly one active project declares a learnings namespace, otherwise at the shared `learnings/` root. If that fails, the learning stays queued on this machine and the next `teamai pull` publishes it; when a learnings checkout teamai refuses stopped it, no pull can until you deal with that checkout as the message says. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 3fad3da38..e769df13a 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -324,7 +324,7 @@ teamai projects set hai-inference # 设置本目录激活的项目(覆盖 teamai projects set hai-inference --dry-run # 预览选择,不保存配置 teamai projects members hai-inference # 查看某项目下注册了哪些成员 -# 管理员:修改 manifest/projects.yaml 并发起 PR(--dry-run 在 #971 合并并恢复 guard 分类后可用) +# 管理员:修改 manifest/projects.yaml 并发起 PR teamai projects add checkout --namespaces common,checkout --name "Checkout" # 首次 add 会创建 projects.yaml teamai projects update checkout --add-namespaces payments --remove-namespaces common teamai projects remove checkout @@ -627,7 +627,7 @@ teamai pull --dry-run # 试运行,不实际修改 没有 `--dry-run` 预览的命令(如 `teamai init`、`teamai hooks remove`、`teamai models add` / `configure` / `remove`、`teamai bind-project` 和 `teamai codebase --extract`)会拒绝该参数:打印 `teamai has no --dry-run preview, nothing was run` 并以退出码 1 结束。 -在 #971 合并前,`remove`、`roles init/add/remove/update`、`projects add/update/remove` 和 `import --from-repo/--from-repo-list` 也拒绝 `--dry-run`;在 #970 合并前,`stats` 和 `recall ` 拒绝该参数。`digest`、`import --from-claude` 和 `import --from-iwiki` 尚无安全预览,也拒绝该参数。`import --from-org`、`--from-mr`、`--dir` 和 `recall feedback` 的预览仍可使用。后合并的 PR 必须恢复已修复命令的 guard 分类。 +`remove`、`roles init/add/remove/update`、`projects add/update/remove` 和 `import --from-repo/--from-repo-list` 支持 `--dry-run`。远程导入源优先于较低优先级的 iWiki 或 Claude 参数。在 #970 合并前,`stats` 和 `recall ` 拒绝该参数。`digest`、`import --from-claude` 和 `import --from-iwiki` 尚无安全预览,也拒绝该参数。`import --from-org`、`--from-mr`、`--dir` 和 `recall feedback` 的预览仍可使用。后合并的 PR 必须恢复已修复命令的 guard 分类。 手动执行 `teamai pull` 会在结束时运行 `teamai doctor` 的检查,并逐条打印失败项及其修复建议——包括它刚刚报告同步的 skill 是否真的落到每个启用工具的磁盘上、且可被读取。全部通过时不会有任何额外输出,退出码也不变。SessionStart hook 路径和 `--dry-run` 完全不运行检查,会话启动速度保持不变。托管平台相关的检查(`gh`/`gf` 认证)留给 `teamai doctor`:这次 pull 刚刚用过该平台。 @@ -779,7 +779,7 @@ Choose namespace [1-3] (default: 1 = common): - 当有多个 namespace 可接收新资源、且没有可供询问的终端(CI、hook、`TEAMAI_NONINTERACTIVE`)时,push 会以退出码 2 停止,列出这些 namespace,并要求使用 `--role ` - `--role`/`--project` 只放置新资源。对共享根目录 rule 或 agent 的修改仍留在共享根目录,push 会给出提示 - 已落点的资源在发布它的机器上仍可维护:PR 未合并期间,待评审 PR 记录会把作者对自己副本的修改带回该 PR;文件进入默认分支后,`state.json` 会记录 push 的落点,因此修改仍会写回同一个文件;即使 agent 落在本目录未激活的 namespace,也不会被当作“无活跃源”跳过 -- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,或本机的落点记录无法更新并保存,`remove` 会以退出码 1 停止且不删除任何内容,因为两者都可能把名字解析到错误的文件。`--dry-run` 只执行 fetch:像真实运行在 pull 之后那样,按 origin 默认分支解析名字,且不保存任何落点记录。克隆模式下 fetch 失败时,预览会使用与真实删除相同的拒绝消息,并以退出码 1 停止。克隆存在未提交更改时,预览也会以退出码 1 拒绝运行,请先 commit 或 stash。业务文件的未提交更改不会阻止单仓模式预览。 +- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,或本机的落点记录无法更新并保存,`remove` 会以退出码 1 停止且不删除任何内容,因为两者都可能把名字解析到错误的文件。`--dry-run` 只执行 fetch:按真实 pull 后的克隆当前分支内容解析名字(单仓模式使用 origin 默认分支),且不保存任何落点记录。克隆模式下 fetch 失败时,预览会使用与真实删除相同的拒绝消息,并以退出码 1 停止。克隆存在未提交更改时,预览也会以退出码 1 拒绝运行,请先 commit 或 stash。业务文件的未提交更改不会阻止单仓模式预览。 - 本地 agent 被视为其来源团队 agent 的编辑:优先是活跃 namespace 中的 agent,其次是本机放置的 agent,最后是被二者替换的共享根目录 agent。只有三者都不存在时,才由 `--role`/`--project` 决定,此时该 agent 在该 namespace 中是新的;若该 namespace 已有同名 agent,则跳过该 agent 而不是覆盖它,与 rule 的处理一致。两个活跃的同名 agent 无论是否指定参数都视为有歧义并跳过。同名 agent 允许存在于多个 namespace,因此你未指定的非活跃 namespace 中的同名副本不会阻止你发布。本机放置的 agent 若在当前检出上次同步后被团队修改,会暂缓推送,因为 agents 没有推送前同步。pull 会保留你修改过的副本,因此请先另存你的修改,删除该副本,执行 `teamai pull --force`,重新应用修改后再 push。单仓库模式下,`.teamai/` 中的根目录副本若与其落点文件的某个旧版本相同,也会暂缓推送:没有任何操作会刷新它,因此它是旧副本而不是编辑 - 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace - 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;它会像活跃 namespace 中的 agent 一样替换共享根目录的同名 agent。若已激活的 namespace 中已有同名 agent,则以它为准 @@ -833,11 +833,11 @@ teamai roles update hai --remove-namespaces legacy -d "新描述" # 删除角色 teamai roles remove devops -# 预览变更(在 #971 合并并恢复 guard 分类后可用) +# 预览变更 teamai roles add test --namespaces common,test --dry-run ``` -`--namespaces` 列表会同时应用到 `knowledge`、`skills` 与 `agents`。以上命令会自动 push 分支并创建 MR,合并后对全团队生效。加 `--dry-run` 时,`teamai roles add/update/remove` 与 `teamai projects add/update/remove` 只 fetch 并读取 origin 默认分支上的 manifest,不会 pull 团队仓库,单仓模式下也不会创建 worktree,因此尚未推送的提交会保留。若 fetch 失败,这些 manifest 预览会警告并使用上次获取的副本,与真实编辑在 pull 失败后警告并继续的策略一致。克隆预览遇到未提交更改时会以退出码 1 拒绝运行,并提示先 commit 或 stash,因为真实 pull 可能保留本地 manifest 编辑。业务文件的未提交更改不会阻止单仓模式预览。克隆模式下,`roles init` 只在检查已有 manifest 和交互提问之前 pull 一次,写入之前不会再次 pull。 +`--namespaces` 列表会同时应用到 `knowledge`、`skills` 与 `agents`。以上命令会自动 push 分支并创建 MR,合并后对全团队生效。加 `--dry-run` 时,`teamai roles init/add/update/remove` 与 `teamai projects add/update/remove` 只 fetch 并读取真实 pull 后的克隆当前分支 manifest(单仓模式使用 origin 默认分支),不会 pull 团队仓库,单仓模式下也不会创建 worktree,因此尚未推送的提交会保留。若 fetch 失败,这些 manifest 预览会警告并使用未改变的克隆检出,单仓模式则使用上次获取的默认分支副本,与真实编辑在 pull 失败后警告并继续的策略一致。克隆预览遇到未提交更改时会以退出码 1 拒绝运行,并提示先 commit 或 stash,因为真实 pull 可能保留本地 manifest 编辑。业务文件的未提交更改不会阻止单仓模式预览。干净克隆预览会保留领先分支、快进落后分支,分叉时使用 origin/当前分支,与真实 pull 一致。`roles init --dry-run` 在临时检出内检查已有 manifest 并询问是否覆盖。克隆模式下,真实 `roles init` 只在检查已有 manifest 和交互提问之前 pull 一次,写入之前不会再次 pull。 **成员操作:** @@ -1767,7 +1767,7 @@ teamai import --from-repo https://github.com/org/repo --skip-enrich 如果核心知识图谱提取或写入失败,导入会报错,且不会将该提交标记为已同步。下次增量导入会重试该提交。 -加 `--dry-run` 时,`--from-repo` 与 `--from-repo-list` 用 `git ls-remote` 读取每个仓库的目标提交,打印 `Would import / at ` 以及本地缓存是否最新,然后停止:不会 clone 或 fetch 到缓存,不会获取导入锁,也不会运行任何 AI 步骤。使用 `--incremental` 且缓存含 `LAST_SYNC` 时,预览会查询该缓存配置的 origin 上当前分支的提交,与真实 fetch/reset 一致。完整克隆预览(包括缺少缓存或 `LAST_SYNC`)跟随远端 HEAD。若缓存分支查询失败,预览会警告并预览完整克隆回退。 +加 `--dry-run` 时,`--from-repo` 与 `--from-repo-list` 用 `git ls-remote` 读取每个仓库的目标提交,打印 `Would import / at ` 以及本地缓存是否最新,然后停止:不会 clone 或 fetch 到缓存,不会获取导入锁,也不会运行任何 AI 步骤。使用 `--incremental` 且缓存含 `LAST_SYNC` 时,预览会查询该缓存配置的 origin 上当前分支的提交,与真实 fetch/reset 一致。完整克隆预览(包括缺少缓存或 `LAST_SYNC`)跟随远端 HEAD。若缓存分支查询失败,预览会警告并预览完整克隆回退。 指定 `--output` 时,预览会显示与真实导入相同的输出文件旁 `teamwiki/evidence/code/` 目标目录。 `--from-mr` 与 `teamai contribute` 一样,把提取的经验发布到 `teamai-learnings` 分支:恰好一个激活项目声明了 learnings namespace 时放在 `learnings//` 下,否则放在共享的 `learnings/` 根目录。发布失败时,经验留在本机队列中,下次 `teamai pull` 会发布它;若阻止发布的是 teamai 拒绝使用的 learnings 检出,则在你按提示处理该检出之前,任何 pull 都无法发布它。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 6e1460371..0e6fd974b 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -134,13 +134,16 @@ teamai projects remove # remove a project A member gets the union of their role resources and their active project's resources. Admins declare projects in `manifest/projects.yaml` with the commands -above, each of which opens a PR. Until #971 lands and restores the guard -classification, `roles init/add/update/remove` and `projects add/update/remove` -refuse `--dry-run` before pulling the team repo. After `projects remove`, +above, each of which opens a PR. `roles init/add/update/remove` and +`projects add/update/remove` support `--dry-run` without pulling the team repo. +Clone previews use the contents after a real pull on the checked-out branch: an +ahead branch stays ahead, a behind branch advances, and a diverged branch uses +origin/current-branch. `roles init` preview checks that manifest before asking +for overwrite confirmation. After `projects remove`, keep the project's content in the team repo until members have pulled: that is what lets their next pull clean up the copies they deployed. -`roles` and `projects` add/update/remove previews, and resource removal +`roles init/add/update/remove` and `projects add/update/remove` previews, and resource removal previews, refuse a team clone with uncommitted changes. Commit or stash them before retrying. Single-repo previews still work with dirty business files because they read origin. diff --git a/src/__tests__/dry-run-guard.test.ts b/src/__tests__/dry-run-guard.test.ts index 2fe1daa6f..da9c5db7b 100644 --- a/src/__tests__/dry-run-guard.test.ts +++ b/src/__tests__/dry-run-guard.test.ts @@ -51,19 +51,29 @@ describe('--dry-run guard decisions', () => { } it.each([ - 'remove', 'roles init', 'roles add', 'roles remove', 'roles update', - 'projects add', 'projects update', 'projects remove', 'stats', 'digest', 'recall', + 'stats', 'digest', 'recall', ])('refuses %s until its writes have a preview', (path) => { expect(dryRunRefusal(command(path))).toBe(`teamai ${path} has no --dry-run preview, nothing was run`); }); - it.each(['--from-repo', '--from-repo-list', '--from-iwiki', '--from-claude'])('refuses import %s', (flag) => { + it.each([ + 'remove', 'roles init', 'roles add', 'roles remove', 'roles update', + 'projects add', 'projects update', 'projects remove', + ])('restores the git preview for %s', (path) => { + expect(dryRunRefusal(command(path))).toBeUndefined(); + }); + + it.each(['--from-iwiki', '--from-claude'])('refuses import %s', (flag) => { const args = flag === '--from-claude' ? [flag] : [flag, 'source']; expect(dryRunRefusal(command('import', args))).toBe(`teamai import ${flag} has no --dry-run preview, nothing was run`); }); it.each([ [], ['--from-org', 'team'], ['--from-mr', 'url'], ['--dir', '.'], + ['--from-repo', 'url'], ['--from-repo-list', 'list'], + ['--from-repo', 'url', '--from-iwiki', 'page', '--from-claude'], + ['--from-repo-list', 'list', '--from-iwiki', 'page', '--from-claude'], + ['--from-org', 'team', '--from-repo', 'url', '--from-repo-list', 'list', '--from-iwiki', 'page', '--from-claude'], ['--from-org', 'team', '--from-repo', 'url'], ['--from-mr', 'url', '--from-claude'], ['--dir', '.', '--from-claude'], ].map((args) => [args]))('preserves safe import source precedence for %j', (args) => { diff --git a/src/__tests__/e2e/dry-run-git-previews-900.test.ts b/src/__tests__/e2e/dry-run-git-previews-900.test.ts index 957668432..84f8b36fd 100644 --- a/src/__tests__/e2e/dry-run-git-previews-900.test.ts +++ b/src/__tests__/e2e/dry-run-git-previews-900.test.ts @@ -8,7 +8,7 @@ * reconciled placement records, and in single-repo mode both commands created * the knowledge worktree. A preview may fetch; it must not move HEAD, touch the * working tree, save state or add a worktree. It still resolves names against - * the default branch on origin, as the real run does. + * the post-pull clone branch, or origin/default in self mode. */ import { afterEach, beforeAll, describe, expect, it } from 'vitest'; import { execFileSync, spawn } from 'node:child_process'; @@ -352,7 +352,7 @@ describe('unreachable origin', () => { for (const args of [['roles', 'add', 'x', '--namespaces', 'x'], ['projects', 'add', 'beta', '--namespaces', 'beta']]) { const result = await dryRun(fixture, args); expect(result.code, result.output).toBe(0); - expect(result.output).toContain('previewing against the copy fetched last.'); + expect(result.output).toContain('previewing against the local checkout.'); expect(result.output).toContain('[dry-run] Would add'); } }); @@ -393,3 +393,66 @@ describe('dirty preview checkouts', () => { expect(fs.readFileSync(path.join(fixture.checkout, 'app.txt'), 'utf8')).toContain('local business edit'); }); }); + + +describe('post-pull branch previews', () => { + function addRole(repo: string, id: string): void { + fs.appendFileSync(path.join(repo, 'manifest', 'roles.yaml'), ` - id: ${id}\n description: branch role\n resources:\n knowledge: [branch]\n skills: [branch]\n agents: [branch]\n`); + git(['add', '-A'], repo); + git(['commit', '-q', '-m', id], repo); + } + + it.each(['pushed', 'ahead', 'behind', 'diverged'])('matches the real pull on a %s feature branch without changing the member checkout', async (state) => { + const fixture = setUpClone(); + git(['fetch', '-q', 'origin'], fixture.checkout); + git(['checkout', '-q', '-b', 'leftover-pr', 'origin/main'], fixture.checkout); + fs.writeFileSync(path.join(fixture.checkout, 'rules', 'feature-rule.md'), '# Feature rule\n'); + fs.appendFileSync(path.join(fixture.checkout, 'manifest', 'projects.yaml'), ' - id: feature-project\n resources:\n knowledge: [feature]\n skills: [feature]\n agents: [feature]\n'); + addRole(fixture.checkout, 'feature-role'); + git(['push', '-q', '-u', 'origin', 'leftover-pr'], fixture.checkout); + let target = 'feature-role'; + if (state === 'ahead' || state === 'diverged') { + addRole(fixture.checkout, 'local-role'); + target = 'local-role'; + } + if (state === 'behind' || state === 'diverged') { + const writer = path.join(path.dirname(fixture.home), 'writer'); + git(['clone', '-q', '-b', 'leftover-pr', git(['remote', 'get-url', 'origin'], fixture.checkout).trim(), writer], fixture.home); + addRole(writer, 'remote-role-2'); + git(['push', '-q', 'origin', 'leftover-pr'], writer); + target = 'remote-role-2'; + } + const preview = await dryRun(fixture, ['roles', 'add', target, '--namespaces', 'branch']); + expect(preview.code, preview.output).toBe(0); + expect(preview.output).toContain(`Role "${target}" already exists`); + const projects = await dryRun(fixture, ['projects', 'add', 'feature-project', '--namespaces', 'feature']); + expect(projects.output).toContain('Project "feature-project" already exists'); + const removal = await dryRun(fixture, ['remove', 'rules', 'feature-rule']); + expect(removal.output).toContain('Will remove 1 rules:'); + const real = await runCLI(['roles', 'add', target, '--namespaces', 'branch'], fixture.cwd, fixture.home); + expect(real.output).toContain(`Role "${target}" already exists`); + expect(git(['branch', '--show-current'], fixture.checkout).trim()).toBe('leftover-pr'); + }); + + it('roles init preview sees an upstream manifest and declines overwrite without changing the stale clone', async () => { + const fixture = setUpClone(); + git(['fetch', '-q', 'origin'], fixture.checkout); + git(['reset', '--hard', 'origin/main'], fixture.checkout); + git(['rm', 'manifest/roles.yaml'], fixture.checkout); + git(['commit', '-q', '-m', 'before roles initialization'], fixture.checkout); + git(['push', '-q', '-u', 'origin', 'main'], fixture.checkout); + const writer = path.join(path.dirname(fixture.home), 'writer'); + git(['clone', '-q', git(['remote', 'get-url', 'origin'], fixture.checkout).trim(), writer], fixture.home); + fs.writeFileSync(path.join(writer, 'manifest', 'roles.yaml'), ROLES_YAML); + git(['add', '-A'], writer); + git(['commit', '-q', '-m', 'another admin initializes roles'], writer); + git(['push', '-q', 'origin', 'main'], writer); + + const result = await dryRun(fixture, ['roles', 'init']); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain('Roles manifest already exists'); + expect(result.output).toContain('Aborted. Existing manifest is unchanged.'); + expect(result.output).not.toContain('Define team roles'); + expect(fs.existsSync(path.join(fixture.checkout, 'manifest', 'roles.yaml'))).toBe(false); + }); +}); diff --git a/src/__tests__/e2e/dry-run-refusal.test.ts b/src/__tests__/e2e/dry-run-refusal.test.ts index c97d40873..ed6aebc4a 100644 --- a/src/__tests__/e2e/dry-run-refusal.test.ts +++ b/src/__tests__/e2e/dry-run-refusal.test.ts @@ -179,7 +179,6 @@ describe('--dry-run on a command with no preview', () => { it.each([ ['stats'], ['digest'], ['recall', 'query'], - ['import', '--from-repo', TEAM_URL], ['import', '--from-repo-list', 'repos.yaml'], ['import', '--from-iwiki', 'page', '--from-mr', 'url'], ['import', '--from-claude'], ])('refuses unsafe preview %j before writing', (...args) => { const before = snapshot(home); @@ -201,6 +200,8 @@ describe('--dry-run on a command with no preview', () => { 'username: tester', 'scope: user', 'provider: git', '', ].join('\n')); const seed = path.join(sandbox, 'seed'); + fs.mkdirSync(path.join(seed, 'manifest'), { recursive: true }); + fs.writeFileSync(path.join(seed, 'manifest', 'roles.yaml'), 'version: 1\nroles:\n - id: existing\n description: Existing\n resources:\n knowledge: [common]\n skills: [common]\n agents: [common]\n'); fs.writeFileSync(path.join(seed, 'remote-ahead.txt'), 'new commit\n'); execFileSync('git', ['add', '-A'], { cwd: seed, env }); execFileSync('git', ['commit', '-qm', 'remote ahead'], { cwd: seed, env }); @@ -212,10 +213,13 @@ describe('--dry-run on a command with no preview', () => { const result = cli(['roles', 'add', 'x', '--namespaces', 'x', '--dry-run']); - expect(result.code, result.output).toBe(1); - expect(result.output).toContain('teamai roles add has no --dry-run preview, nothing was run'); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain('[dry-run] Would add role'); expect(execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' })).toBe(head); - expect(snapshot(home)).toEqual(before); + const applicationFiles = (files: Record) => Object.fromEntries( + Object.entries(files).filter(([name]) => !name.includes(`${path.sep}.git${path.sep}`)), + ); + expect(applicationFiles(snapshot(home))).toEqual(applicationFiles(before)); }); it('refuses CI artifact output before provider access and preserves no-output previews', () => { diff --git a/src/__tests__/e2e/import-repo-dry-run-900.test.ts b/src/__tests__/e2e/import-repo-dry-run-900.test.ts index 83eb871d9..d418feac9 100644 --- a/src/__tests__/e2e/import-repo-dry-run-900.test.ts +++ b/src/__tests__/e2e/import-repo-dry-run-900.test.ts @@ -230,10 +230,25 @@ describe('import --from-repo --dry-run (#900 C3)', () => { expect(result.output).not.toContain(main.slice(0, 8)); }); + it('reports the actual --output destination without creating it', async () => { + const output = path.join(sandbox, 'custom', 'summary.md'); + const before = tree(path.dirname(output)); + const result = await dryRun(['import', '--from-repo', URL, '--output', output]); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain(path.join(path.resolve(output, '..', 'teamwiki'), 'evidence', 'code', 'git__acme__widget')); + expect(tree(path.dirname(output))).toEqual(before); + }); + + it('selects a remote repo before lower-priority unsafe sources', async () => { + const result = await dryRun(['import', '--from-repo', URL, '--from-iwiki', 'page', '--from-claude']); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain('Would import acme/widget'); + }); + it('previews every entry of --from-repo-list', async () => { const list = path.join(sandbox, 'repos.yaml'); fs.writeFileSync(list, `repos:\n - url: ${URL}\n`); - const result = await dryRun(['import', '--from-repo-list', list]); + const result = await dryRun(['import', '--from-repo-list', list, '--from-iwiki', 'page', '--from-claude']); expect(result.code, result.output).toBe(0); expect(result.output).toContain(`[dry-run] Would import acme/widget at ${head.slice(0, 8)}`); }); diff --git a/src/dry-run-guard.ts b/src/dry-run-guard.ts index fe0d6a71e..de7d67419 100644 --- a/src/dry-run-guard.ts +++ b/src/dry-run-guard.ts @@ -10,6 +10,7 @@ import type { Command } from 'commander'; * guards its writes) or only reads. */ export const DRY_RUN_PREVIEW: ReadonlySet = new Set([ + 'remove', 'push', 'pull', 'status', @@ -30,9 +31,16 @@ export const DRY_RUN_PREVIEW: ReadonlySet = new Set([ 'doctor', 'roles', 'roles list', + 'roles init', + 'roles add', + 'roles remove', + 'roles update', 'roles set', 'projects', 'projects list', + 'projects add', + 'projects update', + 'projects remove', 'projects set', 'projects members', 'tags', @@ -85,14 +93,6 @@ export const DRY_RUN_PREVIEW: ReadonlySet = new Set([ * on a command that is in neither. */ export const NO_DRY_RUN_PREVIEW: Readonly> = { - 'remove': 'pulls the team repo before its preview until #971 lands', - 'roles init': 'pulls the team repo before its preview until #971 lands', - 'roles add': 'pulls the team repo before its preview until #971 lands', - 'roles remove': 'pulls the team repo before its preview until #971 lands', - 'roles update': 'pulls the team repo before its preview until #971 lands', - 'projects add': 'pulls the team repo before its preview until #971 lands', - 'projects update': 'pulls the team repo before its preview until #971 lands', - 'projects remove': 'pulls the team repo before its preview until #971 lands', stats: 'creates or refreshes the reports worktree until #970 lands', recall: 'writes recall quality and the search index until #970 lands', digest: 'creates or refreshes the reports worktree; #900 C11', @@ -132,9 +132,7 @@ export function dryRunRefusal(command: Command): string | undefined { if (path === 'import') { const opts = command.opts(); // Match importCmd's source precedence. #960 made --from-org preview-safe. - if (!opts.fromOrg) { - if (opts.fromRepo) return noDryRunPreview('import --from-repo'); // clones/fetches the cache until #971 lands - if (opts.fromRepoList) return noDryRunPreview('import --from-repo-list'); // same cache writes until #971 lands + if (!opts.fromOrg && !opts.fromRepo && !opts.fromRepoList) { if (opts.fromIwiki) return noDryRunPreview('import --from-iwiki'); // persists the review session if (!opts.fromMr && !opts.dir && opts.fromClaude) return noDryRunPreview('import --from-claude'); // persists the review session } diff --git a/src/import-repo.ts b/src/import-repo.ts index d63e1363c..7ae16e90c 100644 --- a/src/import-repo.ts +++ b/src/import-repo.ts @@ -213,6 +213,7 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise const lastSync = await readLastSync(cacheDir); const cacheExists = await fs.pathExists(path.join(cacheDir, '.git')); const useIncremental = incremental && cacheExists && lastSync !== null; + const outputWikiRoot = output ? path.resolve(output, '..', 'teamwiki') : undefined; // A preview asks for the target remote commit and stops: the clone replaces the // cache (or fetches and resets it), and the lock and LLM scan follow (#900). @@ -234,7 +235,7 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise : lastSync?.sha === head ? `cache is current at ${head.slice(0, 8)}${previewIncremental ? ', so an incremental run would skip it' : ''}` : `cache would be refreshed from ${lastSync ? lastSync.sha.slice(0, 8) : 'an unrecorded commit'} to ${head.slice(0, 8)}`; - log.info(`[dry-run] Would import ${owner}/${repoName} at ${head.slice(0, 8)} into teamwiki/evidence/code/${slug}; ${cache}`); + log.info(`[dry-run] Would import ${owner}/${repoName} at ${head.slice(0, 8)} into ${path.join(outputWikiRoot ?? 'teamwiki', 'evidence', 'code', slug)}; ${cache}`); return; } @@ -328,9 +329,7 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise const releaseImportLock = await acquireImportLock(teamRepoDir); try { // 4. Generate teamwiki/ knowledge graph artifacts + append AI narrative to overview.md - const teamwikiRoot = output - ? path.resolve(output, '..', 'teamwiki') - : path.join(teamRepoDir, 'teamwiki'); + const teamwikiRoot = outputWikiRoot ?? path.join(teamRepoDir, 'teamwiki'); if (!dryRun) { const cacheWiki = path.join(cacheDir, 'teamwiki'); try { diff --git a/src/manifest-edit.ts b/src/manifest-edit.ts index dcd228d48..b87aed870 100644 --- a/src/manifest-edit.ts +++ b/src/manifest-edit.ts @@ -20,8 +20,8 @@ export async function pullLatest(repoPath: string): Promise { * Run a manifest admin edit (write manifest + open PR) against the right repo. * In single-repo mode the manifest is knowledge on main, so the edit runs inside * an isolated knowledge worktree (never the user's active tree); otherwise the - * team repo clone is pulled first. A dry run reads origin/ from a - * throwaway checkout instead and never pulls, since the pull can reset a clone + * team repo clone is pulled first. A dry run reads the post-pull branch contents + * from a throwaway checkout instead and never pulls, since the pull can reset a clone * with unpushed commits (#900). `fn` receives the repoPath to read/write the * manifest and the localConfig to use for the PR, both scoped to that checkout. */ diff --git a/src/remove.ts b/src/remove.ts index 5efaaa603..9c3f3e1c5 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -40,7 +40,7 @@ export async function remove( // Single-repo mode: run the removal PR in an isolated knowledge worktree so the // branch/commit never touches the user's active tree. A dry run reads a - // throwaway checkout of origin/ instead, in either mode: the pull + // throwaway checkout of the post-pull branch contents instead: the pull // below can reset a clone with unpushed commits (#900). if (options.dryRun || localConfig.repo.kind === 'self') { const { withKnowledgeWorktree, withDefaultBranchPreview, EmptyRepoError, PreviewFetchError, DirtyPreviewError } = await import('./utils/reports-branch.js'); @@ -86,7 +86,7 @@ async function removeCore( teamConfig: TeamaiConfig, ): Promise { // Pull latest before making changes. In self mode and on a dry run the - // checkout is already a fresh copy of origin/, so skip the pull. + // checkout already models the refreshed contents, so skip the pull. // A clone that could not be refreshed is not the default branch: a placement // merged since the last pull is not recorded there, so the bare name the // author types falls back to the stem and removes that agent from every diff --git a/src/roles-cmd.ts b/src/roles-cmd.ts index 99c432936..41f6b1485 100644 --- a/src/roles-cmd.ts +++ b/src/roles-cmd.ts @@ -6,7 +6,7 @@ import type { RolesManifest, TeamRole } from './roles.js'; import { pathExists } from './utils/fs.js'; import { log } from './utils/logger.js'; import { pullLatest, runManifestEdit, pushManifestChange } from './manifest-edit.js'; -import type { GlobalOptions } from './types.js'; +import type { GlobalOptions, LocalConfig, TeamaiConfig } from './types.js'; import { askQuestion, askConfirmation } from './utils/prompt.js'; /** @@ -23,15 +23,18 @@ function parseNamespaces(input: string): string[] { export async function rolesInit(options: GlobalOptions): Promise { const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); + if (options.dryRun && localConfig.repo.kind !== 'self') { + await runManifestEdit(localConfig, 'Roles', async (_repoPath, previewConfig) => { + await initializeRoles(options, previewConfig, teamConfig); + }, { dryRun: true }); + return; + } + if (!options.dryRun && localConfig.repo.kind !== 'self') await pullLatest(localConfig.repo.localPath); + await initializeRoles(options, localConfig, teamConfig); +} + +async function initializeRoles(options: GlobalOptions, localConfig: LocalConfig, teamConfig: TeamaiConfig): Promise { const repoPath = localConfig.repo.localPath; - const selfMode = localConfig.repo.kind === 'self'; - - // In self mode the manifest is knowledge on main; the on-disk .teamai already - // reflects main, so we read the existence check from there and only run the - // actual write+PR inside an isolated worktree (below). Non-self modes pull the - // team repo clone first, except on a dry run: the pull can reset a clone with - // unpushed commits (#900), so the preview checks the clone as it is. - if (!selfMode && !options.dryRun) await pullLatest(repoPath); // Check if manifest already exists const manifestPath = path.join(repoPath, 'manifest', 'roles.yaml'); diff --git a/src/utils/reports-branch.ts b/src/utils/reports-branch.ts index 3fe3cd64f..814922190 100644 --- a/src/utils/reports-branch.ts +++ b/src/utils/reports-branch.ts @@ -6,7 +6,7 @@ * instance of it and learnings are the other. What stays here is the reports * spec, the names its callers already import, and the knowledge worktree, which * is not a side branch at all: it is a throwaway checkout of the default branch, - * as is the preview checkout a dry run reads instead. + * while a dry-run checkout models the contents after a real refresh. */ import os from 'node:os'; import path from 'node:path'; @@ -254,9 +254,8 @@ export class DirtyPreviewError extends Error { } /** - * Run `fn` against a throwaway checkout of origin/: the tree a real - * `remove` or manifest edit reads after its pull (or, in single-repo mode, in - * its knowledge worktree), for a `--dry-run` that must not change anything. + * Run `fn` against a throwaway checkout of the post-pull clone branch, or + * origin/ in self mode, without changing the member checkout. * * Only a fetch touches the member's repo. Pulling there can reset a clone * that diverged from origin and discard its commits, and `withKnowledgeWorktree` @@ -278,14 +277,34 @@ export async function withDefaultBranchPreview( throw new DirtyPreviewError(repoRoot); } const defaultBranch = await getDefaultBranch(repoRoot); + const branch = selfMode ? defaultBranch : (await git.revparse(['--abbrev-ref', 'HEAD'])).trim(); + let fetched = true; try { - await git.fetch(['origin', defaultBranch]); + await git.fetch(selfMode ? ['origin', branch] : ['origin']); } catch (e) { if (!options.allowStale) throw new PreviewFetchError(e); - log.warn(`Could not fetch origin/${defaultBranch} (${(e as Error).message}); previewing against the copy fetched last.`); + fetched = false; + log.warn(`Could not fetch origin/${branch} (${(e as Error).message}); ${selfMode ? 'previewing against the copy fetched last.' : 'previewing against the local checkout.'}`); + } + const head = await getHeadCommit(repoRoot); + // Self mode really creates a default-branch worktree. Clone mode pulls the + // active branch: ff-only retains an ahead HEAD, advances a behind HEAD, + // and pullRepo's divergence fallback resets to origin/. + let base = await getHeadCommit(repoRoot, `origin/${branch}`) ?? head; + if (!selfMode) { + if (!fetched) { + base = head; // pullLatest warns and continues with this unchanged checkout. + } else { + const upstream = await getHeadCommit(repoRoot, '@{upstream}'); + if (head && upstream) { + try { + const common = (await git.raw(['merge-base', head, upstream])).trim(); + if (common === upstream) base = head; + else if (common === head) base = upstream; + } catch { /* no common ancestor: pullRepo resets to origin/ */ } + } + } } - // origin/ may not exist locally (fresh repo); fall back to HEAD, as the worktree does. - const base = await getHeadCommit(repoRoot, `origin/${defaultBranch}`) ?? await getHeadCommit(repoRoot); if (!base) throw new EmptyRepoError(repoRoot); const dir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-preview-')); @@ -293,9 +312,14 @@ export async function withDefaultBranchPreview( await git.raw(['clone', '--quiet', '--shared', '--no-checkout', repoRoot, dir]); const preview = createGit(dir); await preview.raw(['checkout', '--quiet', '--detach', base]); - // The clone's origin/* are the member's local branches; point the default - // branch at what origin has, as it is in the member's repo. - await preview.raw(['update-ref', `refs/remotes/origin/${defaultBranch}`, base]); + // A shared clone's origin/* initially name the member's local branches. + // History/name resolution must instead see the member's fetched refs, + // including an ahead HEAD that differs from origin/. + const refs = await git.raw(['for-each-ref', '--format=%(refname) %(objectname)', 'refs/remotes/origin/']); + for (const entry of refs.trim().split('\n').filter(Boolean)) { + const [ref, commit] = entry.split(' '); + if (ref !== 'refs/remotes/origin/HEAD') await preview.raw(['update-ref', ref, commit]); + } await preview.raw(['symbolic-ref', 'refs/remotes/origin/HEAD', `refs/remotes/origin/${defaultBranch}`]); const previewConfig: LocalConfig = { ...localConfig, From 0dd1ab512e525903f19f3cfd2c41af97e74e3b06 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Sun, 4 Oct 2026 16:44:29 +0200 Subject: [PATCH 7/8] fix(dry-run): match missing remote branch refresh outcomes (#900) --- docs/usage-guide.md | 4 +-- docs/usage-guide.zh-CN.md | 4 +-- skill-data/setup/references/manage-admin.md | 4 ++- .../e2e/dry-run-git-previews-900.test.ts | 17 ++++++++++ .../e2e/import-repo-dry-run-900.test.ts | 31 +++++++++++++++++++ src/clone.ts | 17 ++++++++++ src/utils/reports-branch.ts | 2 +- 7 files changed, 73 insertions(+), 6 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index a339bc69a..807a91726 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -873,7 +873,7 @@ Choose namespace [1-3] (default: 1 = common): - When several namespaces could take a new resource and there is no terminal to ask on (CI, a hook, `TEAMAI_NONINTERACTIVE`), push stops with exit 2, lists them, and asks for `--role ` - `--role`/`--project` places new resources only. An edit of a shared-root rule or agent stays at the shared root, and push says so - A placed resource stays maintainable from the machine that published it. While its PR is open, the open-PR record routes a later edit of the author's own copy back to that PR; once the file is on the default branch, `state.json` records where push put it, so the edit goes back to the same file, and an agent published into a namespace this directory has not activated is still editable rather than skipped as having no active source -- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, or this machine's placement records cannot be updated and saved, `remove` stops with exit 1 and removes nothing, because either can resolve the name to the wrong files. `--dry-run` only fetches: it resolves names against the contents a real pull would use on the clone's checked-out branch (or origin's default branch in self mode), and saves no records. In clone mode, a failed fetch stops the preview with the same refusal and exit 1 as a real removal. A clone with uncommitted changes is refused with exit 1; commit or stash them before previewing. Dirty business files do not block self-mode previews. +- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, or this machine's placement records cannot be updated and saved, `remove` stops with exit 1 and removes nothing, because either can resolve the name to the wrong files. `--dry-run` only fetches: it resolves names against the contents a real pull would use on the clone's checked-out branch (or origin's default branch in self mode), and saves no records. Removal fetches the active branch explicitly, so a local-only branch without that remote branch is refused just like a real refresh. In clone mode, a failed fetch stops the preview with the same refusal and exit 1 as a real removal. A clone with uncommitted changes is refused with exit 1; commit or stash them before previewing. Dirty business files do not block self-mode previews. - A local agent is an edit of the team agent it was delivered from: one in an active namespace first, then one this machine placed, then the shared-root agent either of them replaces. Only when none exists does `--role`/`--project` decide, and the agent is new in that namespace; if that namespace already holds an agent of that name, the agent is skipped rather than written over it, as a rule would be. Two active agents of one name stay ambiguous and are skipped, flag or not. The same agent name may exist in several namespaces, so a copy in an inactive one you did not name never blocks publishing yours. A placed agent that changed on the team since this checkout last synced it is held, because agents have no pre-push sync. Pull keeps your changed copy, so save your edit, delete the copy, run `teamai pull --force`, reapply the edit and push again. In single-repo mode, a root copy under `.teamai/` that matches an older version of the file it was placed at is held too: nothing refreshes it, so it is an old copy rather than an edit - A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` - An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. It replaces a shared-root agent of the same name, as an active namespace's agent would. An active namespace holding that name wins: that agent is the one deployed here @@ -1922,7 +1922,7 @@ teamai import --from-repo https://github.com/org/repo --skip-enrich If core graph extraction or writing fails, the import reports an error without marking the commit as synced. The next incremental run retries that commit. -With `--dry-run`, `--from-repo` and `--from-repo-list` read each repo's target commit with `git ls-remote`, print `Would import / at ` and whether the local cache is current, and stop there: nothing is cloned or fetched into the cache, the import lock is not taken, and no AI step runs. With `--incremental` and a cache containing `LAST_SYNC`, the preview queries that cache's current branch at its configured origin, matching the real fetch/reset. Full-clone previews, including a missing cache or `LAST_SYNC`, follow remote HEAD. If the cached-branch query fails, the preview warns and previews the full-clone fallback. With `--output`, the preview reports the same `teamwiki/evidence/code/` destination beside the output file as a real import. +With `--dry-run`, `--from-repo` and `--from-repo-list` read each repo's target commit with `git ls-remote`, print `Would import / at ` and whether the local cache is current, and stop there: nothing is cloned or fetched into the cache, the import lock is not taken, and no AI step runs. With `--incremental` and a cache containing `LAST_SYNC`, the preview queries that cache's current branch at its configured origin, matching the real fetch/reset. Full-clone previews, including a missing cache or `LAST_SYNC`, follow remote HEAD. If the cached branch was deleted remotely, a non-pruning wildcard fetch retains its cached origin ref; incremental preview uses that retained commit too. Pruning, an explicit deleted-branch fetch refspec, or a missing cached origin ref still selects the full-clone fallback. Other cached-branch query failures warn and preview the full-clone fallback. With `--output`, the preview reports the same `teamwiki/evidence/code/` destination beside the output file as a real import. `--from-mr` publishes its learning the way `teamai contribute` does, on the `teamai-learnings` branch: under `learnings//` when exactly one active project declares a learnings namespace, otherwise at the shared `learnings/` root. If that fails, the learning stays queued on this machine and the next `teamai pull` publishes it; when a learnings checkout teamai refuses stopped it, no pull can until you deal with that checkout as the message says. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index e769df13a..b0759bece 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -779,7 +779,7 @@ Choose namespace [1-3] (default: 1 = common): - 当有多个 namespace 可接收新资源、且没有可供询问的终端(CI、hook、`TEAMAI_NONINTERACTIVE`)时,push 会以退出码 2 停止,列出这些 namespace,并要求使用 `--role ` - `--role`/`--project` 只放置新资源。对共享根目录 rule 或 agent 的修改仍留在共享根目录,push 会给出提示 - 已落点的资源在发布它的机器上仍可维护:PR 未合并期间,待评审 PR 记录会把作者对自己副本的修改带回该 PR;文件进入默认分支后,`state.json` 会记录 push 的落点,因此修改仍会写回同一个文件;即使 agent 落在本目录未激活的 namespace,也不会被当作“无活跃源”跳过 -- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,或本机的落点记录无法更新并保存,`remove` 会以退出码 1 停止且不删除任何内容,因为两者都可能把名字解析到错误的文件。`--dry-run` 只执行 fetch:按真实 pull 后的克隆当前分支内容解析名字(单仓模式使用 origin 默认分支),且不保存任何落点记录。克隆模式下 fetch 失败时,预览会使用与真实删除相同的拒绝消息,并以退出码 1 停止。克隆存在未提交更改时,预览也会以退出码 1 拒绝运行,请先 commit 或 stash。业务文件的未提交更改不会阻止单仓模式预览。 +- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,或本机的落点记录无法更新并保存,`remove` 会以退出码 1 停止且不删除任何内容,因为两者都可能把名字解析到错误的文件。`--dry-run` 只执行 fetch:按真实 pull 后的克隆当前分支内容解析名字(单仓模式使用 origin 默认分支),且不保存任何落点记录。删除预览会显式 fetch 当前分支,因此没有对应远程分支的本地分支会像真实刷新一样被拒绝。克隆模式下 fetch 失败时,预览会使用与真实删除相同的拒绝消息,并以退出码 1 停止。克隆存在未提交更改时,预览也会以退出码 1 拒绝运行,请先 commit 或 stash。业务文件的未提交更改不会阻止单仓模式预览。 - 本地 agent 被视为其来源团队 agent 的编辑:优先是活跃 namespace 中的 agent,其次是本机放置的 agent,最后是被二者替换的共享根目录 agent。只有三者都不存在时,才由 `--role`/`--project` 决定,此时该 agent 在该 namespace 中是新的;若该 namespace 已有同名 agent,则跳过该 agent 而不是覆盖它,与 rule 的处理一致。两个活跃的同名 agent 无论是否指定参数都视为有歧义并跳过。同名 agent 允许存在于多个 namespace,因此你未指定的非活跃 namespace 中的同名副本不会阻止你发布。本机放置的 agent 若在当前检出上次同步后被团队修改,会暂缓推送,因为 agents 没有推送前同步。pull 会保留你修改过的副本,因此请先另存你的修改,删除该副本,执行 `teamai pull --force`,重新应用修改后再 push。单仓库模式下,`.teamai/` 中的根目录副本若与其落点文件的某个旧版本相同,也会暂缓推送:没有任何操作会刷新它,因此它是旧副本而不是编辑 - 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace - 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;它会像活跃 namespace 中的 agent 一样替换共享根目录的同名 agent。若已激活的 namespace 中已有同名 agent,则以它为准 @@ -1767,7 +1767,7 @@ teamai import --from-repo https://github.com/org/repo --skip-enrich 如果核心知识图谱提取或写入失败,导入会报错,且不会将该提交标记为已同步。下次增量导入会重试该提交。 -加 `--dry-run` 时,`--from-repo` 与 `--from-repo-list` 用 `git ls-remote` 读取每个仓库的目标提交,打印 `Would import / at ` 以及本地缓存是否最新,然后停止:不会 clone 或 fetch 到缓存,不会获取导入锁,也不会运行任何 AI 步骤。使用 `--incremental` 且缓存含 `LAST_SYNC` 时,预览会查询该缓存配置的 origin 上当前分支的提交,与真实 fetch/reset 一致。完整克隆预览(包括缺少缓存或 `LAST_SYNC`)跟随远端 HEAD。若缓存分支查询失败,预览会警告并预览完整克隆回退。 指定 `--output` 时,预览会显示与真实导入相同的输出文件旁 `teamwiki/evidence/code/` 目标目录。 +加 `--dry-run` 时,`--from-repo` 与 `--from-repo-list` 用 `git ls-remote` 读取每个仓库的目标提交,打印 `Would import / at ` 以及本地缓存是否最新,然后停止:不会 clone 或 fetch 到缓存,不会获取导入锁,也不会运行任何 AI 步骤。使用 `--incremental` 且缓存含 `LAST_SYNC` 时,预览会查询该缓存配置的 origin 上当前分支的提交,与真实 fetch/reset 一致。完整克隆预览(包括缺少缓存或 `LAST_SYNC`)跟随远端 HEAD。若缓存分支已从远端删除,不执行 prune 的通配 fetch 会保留缓存 origin 引用,增量预览也使用该保留提交。启用 prune、显式 fetch 已删除分支或缺少缓存 origin 引用时,仍预览完整克隆回退。其他缓存分支查询失败时,预览会警告并预览完整克隆回退。 指定 `--output` 时,预览会显示与真实导入相同的输出文件旁 `teamwiki/evidence/code/` 目标目录。 `--from-mr` 与 `teamai contribute` 一样,把提取的经验发布到 `teamai-learnings` 分支:恰好一个激活项目声明了 learnings namespace 时放在 `learnings//` 下,否则放在共享的 `learnings/` 根目录。发布失败时,经验留在本机队列中,下次 `teamai pull` 会发布它;若阻止发布的是 teamai 拒绝使用的 learnings 检出,则在你按提示处理该检出之前,任何 pull 都无法发布它。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 0e6fd974b..9a35895c8 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -145,7 +145,9 @@ what lets their next pull clean up the copies they deployed. `roles init/add/update/remove` and `projects add/update/remove` previews, and resource removal previews, refuse a team clone with uncommitted changes. Commit or stash them before retrying. Single-repo -previews still work with dirty business files because they read origin. +previews still work with dirty business files because they read origin. Removal +previews fetch the active branch explicitly and refuse a local-only branch with +no matching remote branch, just like the real refresh. Every namespace that names a directory — `knowledge`, `skills`, `agents`, `env`, `hooks`, `mcp`, `models` and `docs` in either manifest, and `learnings` in `projects.yaml` (a role's `learnings:` is diff --git a/src/__tests__/e2e/dry-run-git-previews-900.test.ts b/src/__tests__/e2e/dry-run-git-previews-900.test.ts index 84f8b36fd..1edfc1542 100644 --- a/src/__tests__/e2e/dry-run-git-previews-900.test.ts +++ b/src/__tests__/e2e/dry-run-git-previews-900.test.ts @@ -396,6 +396,23 @@ describe('dirty preview checkouts', () => { describe('post-pull branch previews', () => { + it('refuses removal on a local-only branch just as the real refresh does', async () => { + const fixture = setUpClone(); + git(['checkout', '-q', '--no-track', '-b', 'local-only'], fixture.checkout); + const preview = await dryRun(fixture, ['remove', 'rules', 'doomed']); + expect(preview.code, preview.output).toBe(1); + expect(preview.output).toContain('The team repo could not be refreshed'); + expect(preview.output).not.toContain('Will remove'); + const before = snapshot(fixture); + const real = await runCLI(['remove', 'rules', 'doomed'], fixture.cwd, fixture.home); + expect(real.code, real.output).toBe(1); + expect(real.output).toContain('The team repo could not be refreshed'); + expect(snapshot(fixture)).toEqual(before); + const manifest = await dryRun(fixture, ['roles', 'add', 'x', '--namespaces', 'x']); + expect(manifest.code, manifest.output).toBe(0); + expect(manifest.output).toContain('[dry-run] Would add role'); + }); + function addRole(repo: string, id: string): void { fs.appendFileSync(path.join(repo, 'manifest', 'roles.yaml'), ` - id: ${id}\n description: branch role\n resources:\n knowledge: [branch]\n skills: [branch]\n agents: [branch]\n`); git(['add', '-A'], repo); diff --git a/src/__tests__/e2e/import-repo-dry-run-900.test.ts b/src/__tests__/e2e/import-repo-dry-run-900.test.ts index d418feac9..8ffd72a20 100644 --- a/src/__tests__/e2e/import-repo-dry-run-900.test.ts +++ b/src/__tests__/e2e/import-repo-dry-run-900.test.ts @@ -13,6 +13,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { shallowFetch } from '../../clone.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..', '..', '..'); @@ -194,6 +195,36 @@ describe('import --from-repo --dry-run (#900 C3)', () => { expect(git(['rev-parse', 'HEAD'], cacheDir).trim()).toBe(head); }); + it('incremental preview retains a deleted cached branch when a non-pruning fetch does', async () => { + const main = changeDefaultAfterCachingMaster(); + git(['branch', '-D', 'master'], remote); + const result = await dryRun(['import', '--from-repo', URL, '--incremental']); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain(`Would import acme/widget at ${head.slice(0, 8)}`); + expect(result.output).toContain('so an incremental run would skip it'); + expect(result.output).not.toContain(main.slice(0, 8)); + const real = await runCLI(['import', '--from-repo', URL, '--incremental']); + expect(real.code, real.output).toBe(0); + expect(real.output).toContain(`SHA unchanged (${head.slice(0, 8)})`); + expect(git(['rev-parse', 'HEAD'], cacheDir).trim()).toBe(head); + const full = await dryRun(['import', '--from-repo', URL]); + expect(full.output).toContain(`Would import acme/widget at ${main.slice(0, 8)}`); + }); + + it.each(['pruning', 'single-branch', 'missing origin ref'])('previews the full-clone fallback after branch deletion with %s', async (setting) => { + const main = changeDefaultAfterCachingMaster(); + git(['branch', '-D', 'master'], remote); + if (setting === 'pruning') git(['config', 'remote.origin.prune', 'true'], cacheDir); + else if (setting === 'single-branch') git(['config', 'remote.origin.fetch', '+refs/heads/master:refs/remotes/origin/master'], cacheDir); + else git(['update-ref', '-d', 'refs/remotes/origin/master'], cacheDir); + const result = await dryRun(['import', '--from-repo', URL, '--incremental']); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain('preview refresh failed, previewing a full clone instead'); + expect(result.output).toContain(`Would import acme/widget at ${main.slice(0, 8)}`); + expect(result.output).not.toContain('so an incremental run would skip it'); + await expect(shallowFetch(cacheDir, { provider: 'git' })).rejects.toThrow(); + }); + it('full-clone preview follows remote HEAD even when the cache is on master', async () => { const main = changeDefaultAfterCachingMaster(); const result = await dryRun(['import', '--from-repo', URL]); diff --git a/src/clone.ts b/src/clone.ts index c6502752a..22efe7062 100644 --- a/src/clone.ts +++ b/src/clone.ts @@ -387,6 +387,23 @@ export async function remoteHeadSha( throw new Error(`git ls-remote failed (exit ${code}): ${redactToken(stderr.trim())}`); } const sha = stdout.trim().split(/\s+/)[0]; + if (!sha && cachedRepoPath) { + // shallowFetch does not request pruning. A wildcard fetch can retain a + // deleted branch's origin ref, which its reset still uses. An explicit + // branch refspec instead fails fetching the deleted branch; configured + // pruning removes the ref. Both must keep the full-clone fallback. + const config = await runCommand('git', ['config', '--get-regexp', '^remote\\.origin\\.fetch$'], { cwd: cachedRepoPath, timeoutMs }); + if (config.code !== 0 && config.code !== 1) throw new Error(`Could not read cached fetch settings: ${redactToken(config.stderr.trim())}`); + const entries = config.stdout.trim().split('\n').map((line) => line.split(/\s+/)); + let prune = await runCommand('git', ['config', '--type=bool', '--get', 'remote.origin.prune'], { cwd: cachedRepoPath, timeoutMs }); + if (prune.code === 1) prune = await runCommand('git', ['config', '--type=bool', '--get', 'fetch.prune'], { cwd: cachedRepoPath, timeoutMs }); + if (prune.code !== 0 && prune.code !== 1) throw new Error(`Could not read cached prune setting: ${redactToken(prune.stderr.trim())}`); + const requiresDeletedBranch = entries.some(([key, value]) => key === 'remote.origin.fetch' + && value?.replace(/^\+/, '').split(':')[0] === ref); + if (!requiresDeletedBranch && prune.stdout.trim() !== 'true') { + return await gitCmd(['rev-parse', '--verify', ref.replace('refs/heads/', 'refs/remotes/origin/')], cachedRepoPath, timeoutMs); + } + } if (!sha) throw new Error(`git ls-remote found no ${ref} at ${cachedRepoPath ? 'the cached origin' : sanitizeGitUrl(url)}`); return sha; } diff --git a/src/utils/reports-branch.ts b/src/utils/reports-branch.ts index 814922190..880db494a 100644 --- a/src/utils/reports-branch.ts +++ b/src/utils/reports-branch.ts @@ -280,7 +280,7 @@ export async function withDefaultBranchPreview( const branch = selfMode ? defaultBranch : (await git.revparse(['--abbrev-ref', 'HEAD'])).trim(); let fetched = true; try { - await git.fetch(selfMode ? ['origin', branch] : ['origin']); + await git.fetch(['origin', branch]); } catch (e) { if (!options.allowStale) throw new PreviewFetchError(e); fetched = false; From 20d68c43b3aa008f03901d870601c4194098c04c Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Sun, 4 Oct 2026 17:32:32 +0200 Subject: [PATCH 8/8] fix(dry-run): follow configured upstream before reset fallback (#900) --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/manage-admin.md | 7 ++- .../e2e/dry-run-git-previews-900.test.ts | 59 +++++++++++++++++++ src/utils/reports-branch.ts | 50 +++++++++------- 5 files changed, 93 insertions(+), 27 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 807a91726..d769a0b34 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -873,7 +873,7 @@ Choose namespace [1-3] (default: 1 = common): - When several namespaces could take a new resource and there is no terminal to ask on (CI, a hook, `TEAMAI_NONINTERACTIVE`), push stops with exit 2, lists them, and asks for `--role ` - `--role`/`--project` places new resources only. An edit of a shared-root rule or agent stays at the shared root, and push says so - A placed resource stays maintainable from the machine that published it. While its PR is open, the open-PR record routes a later edit of the author's own copy back to that PR; once the file is on the default branch, `state.json` records where push put it, so the edit goes back to the same file, and an agent published into a namespace this directory has not activated is still editable rather than skipped as having no active source -- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, or this machine's placement records cannot be updated and saved, `remove` stops with exit 1 and removes nothing, because either can resolve the name to the wrong files. `--dry-run` only fetches: it resolves names against the contents a real pull would use on the clone's checked-out branch (or origin's default branch in self mode), and saves no records. Removal fetches the active branch explicitly, so a local-only branch without that remote branch is refused just like a real refresh. In clone mode, a failed fetch stops the preview with the same refusal and exit 1 as a real removal. A clone with uncommitted changes is refused with exit 1; commit or stash them before previewing. Dirty business files do not block self-mode previews. +- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, or this machine's placement records cannot be updated and saved, `remove` stops with exit 1 and removes nothing, because either can resolve the name to the wrong files. `--dry-run` only fetches: it resolves names against the contents a real pull would use on the clone's checked-out branch (or origin's default branch in self mode), and saves no records. Clone previews fetch the configured upstream first, including differently named branches or remotes. If that pull cannot fast-forward or has no upstream, they fetch origin/current-branch to model the real reset fallback. A local-only branch is refused for removal when neither refresh can succeed. In clone mode, a failed fetch stops the preview with the same refusal and exit 1 as a real removal. A clone with uncommitted changes is refused with exit 1; commit or stash them before previewing. Dirty business files do not block self-mode previews. - A local agent is an edit of the team agent it was delivered from: one in an active namespace first, then one this machine placed, then the shared-root agent either of them replaces. Only when none exists does `--role`/`--project` decide, and the agent is new in that namespace; if that namespace already holds an agent of that name, the agent is skipped rather than written over it, as a rule would be. Two active agents of one name stay ambiguous and are skipped, flag or not. The same agent name may exist in several namespaces, so a copy in an inactive one you did not name never blocks publishing yours. A placed agent that changed on the team since this checkout last synced it is held, because agents have no pre-push sync. Pull keeps your changed copy, so save your edit, delete the copy, run `teamai pull --force`, reapply the edit and push again. In single-repo mode, a root copy under `.teamai/` that matches an older version of the file it was placed at is held too: nothing refreshes it, so it is an old copy rather than an edit - A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` - An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. It replaces a shared-root agent of the same name, as an active namespace's agent would. An active namespace holding that name wins: that agent is the one deployed here diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index b0759bece..71c431c0c 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -779,7 +779,7 @@ Choose namespace [1-3] (default: 1 = common): - 当有多个 namespace 可接收新资源、且没有可供询问的终端(CI、hook、`TEAMAI_NONINTERACTIVE`)时,push 会以退出码 2 停止,列出这些 namespace,并要求使用 `--role ` - `--role`/`--project` 只放置新资源。对共享根目录 rule 或 agent 的修改仍留在共享根目录,push 会给出提示 - 已落点的资源在发布它的机器上仍可维护:PR 未合并期间,待评审 PR 记录会把作者对自己副本的修改带回该 PR;文件进入默认分支后,`state.json` 会记录 push 的落点,因此修改仍会写回同一个文件;即使 agent 落在本目录未激活的 namespace,也不会被当作“无活跃源”跳过 -- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,或本机的落点记录无法更新并保存,`remove` 会以退出码 1 停止且不删除任何内容,因为两者都可能把名字解析到错误的文件。`--dry-run` 只执行 fetch:按真实 pull 后的克隆当前分支内容解析名字(单仓模式使用 origin 默认分支),且不保存任何落点记录。删除预览会显式 fetch 当前分支,因此没有对应远程分支的本地分支会像真实刷新一样被拒绝。克隆模式下 fetch 失败时,预览会使用与真实删除相同的拒绝消息,并以退出码 1 停止。克隆存在未提交更改时,预览也会以退出码 1 拒绝运行,请先 commit 或 stash。业务文件的未提交更改不会阻止单仓模式预览。 +- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,或本机的落点记录无法更新并保存,`remove` 会以退出码 1 停止且不删除任何内容,因为两者都可能把名字解析到错误的文件。`--dry-run` 只执行 fetch:按真实 pull 后的克隆当前分支内容解析名字(单仓模式使用 origin 默认分支),且不保存任何落点记录。克隆预览先 fetch 配置的上游,包括名称不同的分支或远程。无法快进或没有上游时,再 fetch origin/当前分支以模拟真实 reset 回退。两种刷新都无法成功时,本地分支的删除预览会拒绝运行。克隆模式下 fetch 失败时,预览会使用与真实删除相同的拒绝消息,并以退出码 1 停止。克隆存在未提交更改时,预览也会以退出码 1 拒绝运行,请先 commit 或 stash。业务文件的未提交更改不会阻止单仓模式预览。 - 本地 agent 被视为其来源团队 agent 的编辑:优先是活跃 namespace 中的 agent,其次是本机放置的 agent,最后是被二者替换的共享根目录 agent。只有三者都不存在时,才由 `--role`/`--project` 决定,此时该 agent 在该 namespace 中是新的;若该 namespace 已有同名 agent,则跳过该 agent 而不是覆盖它,与 rule 的处理一致。两个活跃的同名 agent 无论是否指定参数都视为有歧义并跳过。同名 agent 允许存在于多个 namespace,因此你未指定的非活跃 namespace 中的同名副本不会阻止你发布。本机放置的 agent 若在当前检出上次同步后被团队修改,会暂缓推送,因为 agents 没有推送前同步。pull 会保留你修改过的副本,因此请先另存你的修改,删除该副本,执行 `teamai pull --force`,重新应用修改后再 push。单仓库模式下,`.teamai/` 中的根目录副本若与其落点文件的某个旧版本相同,也会暂缓推送:没有任何操作会刷新它,因此它是旧副本而不是编辑 - 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace - 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;它会像活跃 namespace 中的 agent 一样替换共享根目录的同名 agent。若已激活的 namespace 中已有同名 agent,则以它为准 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 9a35895c8..a220fb41e 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -145,9 +145,10 @@ what lets their next pull clean up the copies they deployed. `roles init/add/update/remove` and `projects add/update/remove` previews, and resource removal previews, refuse a team clone with uncommitted changes. Commit or stash them before retrying. Single-repo -previews still work with dirty business files because they read origin. Removal -previews fetch the active branch explicitly and refuse a local-only branch with -no matching remote branch, just like the real refresh. +previews still work with dirty business files because they read origin. Clone +previews refresh the configured upstream first, even when its branch or remote +name differs. A failed fast-forward or missing upstream uses origin/current-branch +for the real reset fallback. Removal refuses when that refresh also fails. Every namespace that names a directory — `knowledge`, `skills`, `agents`, `env`, `hooks`, `mcp`, `models` and `docs` in either manifest, and `learnings` in `projects.yaml` (a role's `learnings:` is diff --git a/src/__tests__/e2e/dry-run-git-previews-900.test.ts b/src/__tests__/e2e/dry-run-git-previews-900.test.ts index 1edfc1542..c081a3f50 100644 --- a/src/__tests__/e2e/dry-run-git-previews-900.test.ts +++ b/src/__tests__/e2e/dry-run-git-previews-900.test.ts @@ -396,6 +396,65 @@ describe('dirty preview checkouts', () => { describe('post-pull branch previews', () => { + it.each(['origin', 'upstream'])('follows differently named %s/main tracking before origin/release fallback', async (remote) => { + const fixture = setUpClone(); + git(['reset', '--hard', 'HEAD^'], fixture.checkout); + const writer = path.join(path.dirname(fixture.home), 'upstream'); + fs.appendFileSync(path.join(writer, 'manifest', 'projects.yaml'), ' - id: fresh-project\n resources:\n knowledge: [fresh]\n skills: [fresh]\n agents: [fresh]\n'); + git(['add', '-A'], writer); + git(['commit', '-q', '-m', 'upstream project'], writer); + git(['push', '-q', 'origin', 'main'], writer); + if (remote !== 'origin') { + git(['remote', 'add', remote, git(['remote', 'get-url', 'origin'], fixture.checkout).trim()], fixture.checkout); + git(['fetch', '-q', remote], fixture.checkout); + // Keep the configured upstream behind so preview must refresh it. + git(['update-ref', `refs/remotes/${remote}/main`, git(['rev-parse', 'HEAD'], fixture.checkout).trim()], fixture.checkout); + git(['remote', 'set-url', 'origin', path.join(fixture.home, 'missing-origin.git')], fixture.checkout); + } + git(['checkout', '-q', '--no-track', '-b', 'release'], fixture.checkout); + git(['branch', `--set-upstream-to=${remote}/main`, 'release'], fixture.checkout); + const roles = await dryRun(fixture, ['roles', 'add', 'remote-role', '--namespaces', 'remote']); + expect(roles.code, roles.output).toBe(0); + expect(roles.output).toContain('Role "remote-role" already exists'); + expect(roles.output).not.toContain('Could not fetch'); + const removal = await dryRun(fixture, ['remove', 'rules', 'fresh']); + expect(removal.code, removal.output).toBe(0); + expect(removal.output).toContain('Will remove 1'); + const projects = await dryRun(fixture, ['projects', 'add', 'fresh-project', '--namespaces', 'fresh']); + expect(projects.output).toContain('Project "fresh-project" already exists'); + const real = await runCLI(['roles', 'add', 'remote-role', '--namespaces', 'remote'], fixture.cwd, fixture.home); + expect(real.output).toContain('Role "remote-role" already exists'); + expect(git(['branch', '--show-current'], fixture.checkout).trim()).toBe('release'); + expect(git(['rev-parse', 'HEAD'], fixture.checkout).trim()).toBe(git(['rev-parse', `${remote}/main`], fixture.checkout).trim()); + }); + + it('uses origin/local-branch only after the configured upstream diverges', async () => { + const fixture = setUpClone(); + git(['checkout', '-q', '--no-track', '-b', 'release'], fixture.checkout); + git(['branch', '--set-upstream-to=origin/main', 'release'], fixture.checkout); + const writer = path.join(path.dirname(fixture.home), 'upstream'); + git(['checkout', '-q', '-b', 'release'], writer); + fs.appendFileSync(path.join(writer, 'manifest', 'roles.yaml'), ' - id: fallback-role\n description: fallback\n resources:\n knowledge: [fallback]\n skills: [fallback]\n agents: [fallback]\n'); + git(['add', '-A'], writer); + git(['commit', '-q', '-m', 'fallback role'], writer); + git(['push', '-q', '-u', 'origin', 'release'], writer); + const preview = await dryRun(fixture, ['roles', 'add', 'fallback-role', '--namespaces', 'fallback']); + expect(preview.output).toContain('Role "fallback-role" already exists'); + const real = await runCLI(['roles', 'add', 'fallback-role', '--namespaces', 'fallback'], fixture.cwd, fixture.home); + expect(real.output).toContain('Role "fallback-role" already exists'); + expect(git(['rev-parse', 'HEAD'], fixture.checkout).trim()).toBe(git(['rev-parse', 'origin/release'], fixture.checkout).trim()); + }); + + it('uses the origin/current-branch fallback when no upstream is configured', async () => { + const fixture = setUpClone(); + git(['branch', '--unset-upstream'], fixture.checkout); + const preview = await dryRun(fixture, ['roles', 'add', 'remote-role', '--namespaces', 'remote']); + expect(preview.output).toContain('Role "remote-role" already exists'); + const real = await runCLI(['roles', 'add', 'remote-role', '--namespaces', 'remote'], fixture.cwd, fixture.home); + expect(real.output).toContain('Role "remote-role" already exists'); + expect(git(['rev-parse', 'HEAD'], fixture.checkout).trim()).toBe(git(['rev-parse', 'origin/main'], fixture.checkout).trim()); + }); + it('refuses removal on a local-only branch just as the real refresh does', async () => { const fixture = setUpClone(); git(['checkout', '-q', '--no-track', '-b', 'local-only'], fixture.checkout); diff --git a/src/utils/reports-branch.ts b/src/utils/reports-branch.ts index 880db494a..ca0a3230b 100644 --- a/src/utils/reports-branch.ts +++ b/src/utils/reports-branch.ts @@ -278,32 +278,38 @@ export async function withDefaultBranchPreview( } const defaultBranch = await getDefaultBranch(repoRoot); const branch = selfMode ? defaultBranch : (await git.revparse(['--abbrev-ref', 'HEAD'])).trim(); - let fetched = true; - try { - await git.fetch(['origin', branch]); - } catch (e) { - if (!options.allowStale) throw new PreviewFetchError(e); - fetched = false; - log.warn(`Could not fetch origin/${branch} (${(e as Error).message}); ${selfMode ? 'previewing against the copy fetched last.' : 'previewing against the local checkout.'}`); - } const head = await getHeadCommit(repoRoot); - // Self mode really creates a default-branch worktree. Clone mode pulls the - // active branch: ff-only retains an ahead HEAD, advances a behind HEAD, - // and pullRepo's divergence fallback resets to origin/. - let base = await getHeadCommit(repoRoot, `origin/${branch}`) ?? head; - if (!selfMode) { - if (!fetched) { - base = head; // pullLatest warns and continues with this unchanged checkout. + let base: string | null = null; + try { + if (selfMode) { + await git.fetch(['origin', branch]); + base = await getHeadCommit(repoRoot, `origin/${branch}`) ?? head; } else { - const upstream = await getHeadCommit(repoRoot, '@{upstream}'); - if (head && upstream) { - try { - const common = (await git.raw(['merge-base', head, upstream])).trim(); - if (common === upstream) base = head; - else if (common === head) base = upstream; - } catch { /* no common ancestor: pullRepo resets to origin/ */ } + // pullRepo first pulls the configured upstream. FETCH_HEAD also works + // when the remote or its branch has a different name from origin/local. + try { + const remote = (await git.getConfig(`branch.${branch}.remote`)).value; + const merge = (await git.getConfig(`branch.${branch}.merge`)).value; + if (remote && merge) { + await git.fetch([remote, merge]); + const upstream = await getHeadCommit(repoRoot, 'FETCH_HEAD'); + if (head && upstream) { + const common = (await git.raw(['merge-base', head, upstream])).trim(); + if (common === upstream) base = head; + else if (common === head) base = upstream; + } + } + } catch { /* failed ff-only pull: model pullRepo's origin/local fallback */ } + if (!base) { + await git.fetch(['origin', branch]); + base = await getHeadCommit(repoRoot, `origin/${branch}`); + if (!base) throw new Error(`Cannot resolve origin/${branch} after fetching; the pull reset would fail.`); } } + } catch (e) { + if (!options.allowStale) throw new PreviewFetchError(e); + log.warn(`Could not fetch origin/${branch} (${(e as Error).message}); ${selfMode ? 'previewing against the copy fetched last.' : 'previewing against the local checkout.'}`); + base = selfMode ? await getHeadCommit(repoRoot, `origin/${branch}`) ?? head : head; } if (!base) throw new EmptyRepoError(repoRoot);