diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 61bdab923..a2fe0d747 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -634,8 +634,11 @@ Exclusion rules take effect after role and tag filtering. When running `teamai p teamai push # Scan for new/modified resources, create an MR teamai push --all # Skip confirmation, push directly teamai push --role pm # Push into the pm namespace (skills/pm/, rules/pm/, agents/pm/) +teamai push --branch feature/gitee-destination # Use an explicit destination branch ``` +`--branch` names the branch that receives a new push; an existing open PR is always updated on its recorded branch. TeamAI refuses to start a push when the team-repo clone has user changes (modified, staged, untracked, or conflicted files); TeamAI-owned `teamai.yaml` and sync-lock state are handled separately. Commit or stash other local changes first. + **Namespace selection (new resources):** When pushing a new skill, rule or agent, the CLI automatically detects available namespaces and offers an interactive choice: ``` diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 43c2c99f0..beaa3dfef 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -599,8 +599,11 @@ excludedSkills: teamai push # 扫描新增/修改的资源,创建 MR teamai push --all # 跳过确认,直接推送 teamai push --role pm # 推送到 pm namespace(skills/pm/、rules/pm/、agents/pm/) +teamai push --branch feature/gitee-destination # 使用显式目标分支 ``` +`--branch` 指定新推送使用的分支;已有开放 PR 始终沿用其记录的分支进行更新。如果团队仓库 clone 存在用户修改、暂存、未跟踪或冲突文件,TeamAI 会在 push 前拒绝执行;TeamAI 自己管理的 `teamai.yaml` 和 sync-lock 状态会单独处理。其他本地改动请先提交或 stash。 + **命名空间选择(新资源):** 推送新的 skill、rule 或 agent 时,CLI 会自动检测可用的命名空间并提供交互式选择: ``` diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 598e67b55..b2c07e95b 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -37,6 +37,7 @@ Generated: do not edit by hand. Regenerate with - `--skill ` — Push a specific skill by path (e.g., ~/.claude/skills/hai/my-skill or skills/hai_dev/my-skill) - `--role ` — Namespace for new skills, rules and agents (skills//, rules//, agents//) - `--project ` — Target a project: each new resource goes to that project's namespace for its own type — skills, knowledge for rules, agents (from manifest/projects.yaml) + - `--branch ` — Push to this destination branch instead of a generated teamai/push branch ## pull diff --git a/skill-data/core/references/contribute-member.md b/skill-data/core/references/contribute-member.md index eb0fc2721..2f6752217 100644 --- a/skill-data/core/references/contribute-member.md +++ b/skill-data/core/references/contribute-member.md @@ -97,7 +97,10 @@ The doc lands in the team's `learnings/` and appears for teammates on their next new rule and a new agent land in that namespace too (a project resolves each from its own axis — `knowledge` for rules, `agents` for agents). Without one, a new resource whose namespace cannot be resolved stays at the shared root and - reaches the whole team. + reaches the whole team. Use `--branch ` when a new push must target a + specific branch; an existing open PR keeps its recorded branch. TeamAI refuses + to reset a team-repo clone with user changes, so commit or stash unrelated + modified, staged, untracked, or conflicted files before retrying. ## After contributing diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index c5c9f343c..4eb81d0dd 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -16,8 +16,13 @@ existing ones: teamai push # review the diff, then confirm teamai push --all # push everything without per-item confirmation teamai push --skill # push one specific skill +teamai push --branch # use an explicit branch for a new push ``` +An existing open PR is updated on its recorded branch. TeamAI refuses to reset a +team-repo clone with unrelated modified, staged, untracked, or conflicted files; +commit or stash those changes before retrying. + Members receive it automatically the next time they open a session (or when they run `teamai pull`). diff --git a/src/__tests__/push-pending-pr.test.ts b/src/__tests__/push-pending-pr.test.ts index 94fd9175e..4879a7c7c 100644 --- a/src/__tests__/push-pending-pr.test.ts +++ b/src/__tests__/push-pending-pr.test.ts @@ -311,7 +311,7 @@ describe('push() with an open PR', () => { it('updates the open PR instead of opening a second one', async () => { mockLoadStateForScope.mockResolvedValue(makeState([makeEntry()])); - await push({}); + await push({ branch: 'feature/should-not-override' }); expect(mockCreatePullRequest).not.toHaveBeenCalled(); expect(mockPushRepoBranch).toHaveBeenCalledTimes(1); @@ -373,6 +373,16 @@ describe('push() with an open PR', () => { })]); }); + it('uses --branch for a new push instead of generating a timestamp branch', async () => { + mockLoadStateForScope.mockResolvedValue(makeState()); + + await push({ all: true, branch: 'feature/gitee-destination' }); + + expect(mockPushRepoBranch.mock.calls[0][3]).toBe('feature/gitee-destination'); + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as State; + expect(saved.pendingPushes[0].branch).toBe('feature/gitee-destination'); + }); + it('re-pushes as a new PR once the recorded branch is gone from origin', async () => { mockRemoteBranchExists.mockResolvedValue(false); mockLoadStateForScope.mockResolvedValue(makeState([makeEntry()])); diff --git a/src/__tests__/push-role-skill-e2e.test.ts b/src/__tests__/push-role-skill-e2e.test.ts index c49640b1a..717e4f454 100644 --- a/src/__tests__/push-role-skill-e2e.test.ts +++ b/src/__tests__/push-role-skill-e2e.test.ts @@ -122,6 +122,7 @@ async function pullModifyAndPush( fixture: PushFixture, agent: PushAgent, envOverrides: Record = {}, + extraPushArgs: string[] = [], ): Promise { const pullResult = await runCLI( ['pull'], @@ -140,7 +141,7 @@ async function pullModifyAndPush( '---\nname: beta-proof\ndescription: modified\n---\n\n# Modified locally\n', ); return runCLI( - ['push', '--skill', skillPath, '--role', 'backend', '--all'], + ['push', '--skill', skillPath, '--role', 'backend', '--all', ...extraPushArgs], fixture.projectRoot, fixture.home, envOverrides, @@ -278,7 +279,7 @@ describe('role-scoped skill provider PR creation e2e (issue #331)', () => { expect(requests.map((request) => request.url)).toEqual(['/users/sign_in?auto_sign_in=false']); expect(requests[0].headers['private-token']).toBeUndefined(); const config = fs.readFileSync(path.join(fixture.projectRoot, '.teamai', 'team-repo', 'teamai.yaml'), 'utf8'); - expect(config).toContain('provider: git\n'); + expect(config).toMatch(/provider: git\r?\n/); } finally { await new Promise((resolve) => server.close(() => resolve())); fs.rmSync(fixture.sandbox, { recursive: true, force: true }); @@ -290,21 +291,26 @@ describe('role-scoped skill provider PR creation e2e (issue #331)', () => { const binDir = path.join(fixture.sandbox, 'bin'); const ghLog = path.join(fixture.sandbox, 'gh.log'); fs.mkdirSync(binDir); - fs.writeFileSync( - path.join(binDir, 'gh'), - '#!/bin/sh\nprintf "%s\\n" "$*" > "$TEAMAI_FAKE_GH_LOG"\nprintf "%s\\n" "https://github.com/team/issue-331/pull/331"\n', - { mode: 0o755 }, - ); + const ghName = process.platform === 'win32' ? 'gh.cmd' : 'gh'; + const ghScript = process.platform === 'win32' + ? '@echo off\r\n> "%TEAMAI_FAKE_GH_LOG%" echo %*\r\necho https://github.com/team/issue-331/pull/331\r\n' + : '#!/bin/sh\nprintf "%s\\n" "$*" > "$TEAMAI_FAKE_GH_LOG"\nprintf "%s\\n" "https://github.com/team/issue-331/pull/331"\n'; + fs.writeFileSync(path.join(binDir, ghName), ghScript, { mode: 0o755 }); try { + const pathWithoutInstalledGh = (process.env.PATH ?? '') + .split(path.delimiter) + .filter((entry) => !/github\s*cli/i.test(entry)) + .join(path.delimiter); const result = await pullModifyAndPush(fixture, agent, { - PATH: `${binDir}:${process.env.PATH ?? ''}`, + PATH: [binDir, pathWithoutInstalledGh].join(path.delimiter), TEAMAI_FAKE_GH_LOG: ghLog, }); expect(result.code, result.output).toBe(0); expect(result.output).toContain('Pull Request created: https://github.com/team/issue-331/pull/331'); - expect(fs.readFileSync(ghLog, 'utf8')).toContain('pr create -R team/issue-331'); + expect(fs.readFileSync(ghLog, 'utf8')) + .toMatch(/"?pr"?\s+"?create"?\s+"?-R"?\s+"?team\/issue-331"?/); const branch = git( ['for-each-ref', '--format=%(refname:short)', 'refs/heads/teamai/push/issue-331-github/'], @@ -375,3 +381,114 @@ describe('role-scoped skill provider PR creation e2e (issue #331)', () => { } }, 60_000); }); + +describe('push branch and dirty-clone e2e (issue #663)', () => { + it('uses --branch for a real resource push', async () => { + const fixture = makePushFixture('git', 'https://git.example.test/team/issue-663.git', 'claude'); + try { + const result = await pullModifyAndPush( + fixture, + 'claude', + {}, + ['--branch', 'feature/explicit-resource'], + ); + expect(result.code, result.output).not.toBe(0); + expect(result.output).toContain('Pushed branch feature/explicit-resource'); + expect(git(['show', 'feature/explicit-resource:skills/backend/beta-proof/SKILL.md'], fixture.remote)) + .toContain('# Modified locally'); + } finally { + fs.rmSync(fixture.sandbox, { recursive: true, force: true }); + } + }, 60_000); + + it('uses --branch for a teamai.yaml-only push', async () => { + const fixture = makePushFixture('git', 'https://git.example.test/team/issue-663-config.git', 'claude'); + try { + const pulled = await runCLI(['pull'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + const yamlPath = path.join(fixture.projectRoot, '.teamai', 'team-repo', 'teamai.yaml'); + fs.appendFileSync(yamlPath, '\npublicSkills: []\n'); + + const result = await runCLI( + ['push', '--all', '--branch', 'feature/explicit-config'], + fixture.projectRoot, + fixture.home, + ); + expect(result.code, result.output).not.toBe(0); + expect(result.output).toContain('Pushed branch feature/explicit-config'); + expect(git(['show', 'feature/explicit-config:teamai.yaml'], fixture.remote)) + .toContain('publicSkills: []'); + } finally { + fs.rmSync(fixture.sandbox, { recursive: true, force: true }); + } + }, 60_000); + + it('updates an existing open-PR branch instead of creating another branch', async () => { + const fixture = makePushFixture('git', 'https://git.example.test/team/issue-663-existing.git', 'claude'); + try { + const first = await pullModifyAndPush(fixture, 'claude'); + expect(first.output).toContain('Pushed branch teamai/push/issue-331-git/'); + const branch = git( + ['for-each-ref', '--format=%(refname:short)', 'refs/heads/teamai/push/issue-331-git/'], + fixture.remote, + ); + expect(branch).toMatch(/^teamai\/push\/issue-331-git\//); + + const statePath = path.join(fixture.projectRoot, '.teamai', 'state.json'); + const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as { + pendingPushes: Array<{ branch: string; prUrl: string | null }>; + }; + const pending = state.pendingPushes.find((entry) => entry.branch === branch); + expect(pending).toBeDefined(); + if (!pending) return; + pending.prUrl = 'https://github.com/team/issue-663-existing/pull/663'; + fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n'); + + fs.writeFileSync( + path.join(fixture.projectRoot, '.claude', 'skills', 'beta-proof', 'SKILL.md'), + '---\nname: beta-proof\ndescription: modified again\n---\n\n# Modified again\n', + ); + const second = await runCLI( + ['push', '--skill', '.claude/skills/beta-proof', '--role', 'backend', '--all'], + fixture.projectRoot, + fixture.home, + ); + expect(second.code, second.output).toBe(0); + expect(second.output) + .toContain('Existing PR updated: https://github.com/team/issue-663-existing/pull/663'); + expect(git(['show', `${branch}:skills/backend/beta-proof/SKILL.md`], fixture.remote)) + .toContain('# Modified again'); + expect(git( + ['for-each-ref', '--format=%(refname:short)', 'refs/heads/teamai/push/issue-331-git/'], + fixture.remote, + )).toBe(branch); + } finally { + fs.rmSync(fixture.sandbox, { recursive: true, force: true }); + } + }, 60_000); + + it('refuses a mode-only teamai.yaml change before reset --hard', async () => { + const fixture = makePushFixture('git', 'https://git.example.test/team/issue-663-dirty.git', 'claude'); + try { + const teamRepo = path.join(fixture.projectRoot, '.teamai', 'team-repo'); + git(['config', 'core.autocrlf', 'false'], teamRepo); + git(['checkout', '--', 'teamai.yaml'], teamRepo); + const pulled = await runCLI(['pull'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + git(['config', 'core.fileMode', 'true'], teamRepo); + git(['update-index', '--chmod=+x', 'teamai.yaml'], teamRepo); + expect(git(['status', '--short'], teamRepo)).toContain('teamai.yaml'); + + const result = await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + expect(result.code, result.output).not.toBe(0); + expect(result.output).toContain('Cannot push: the team repo has uncommitted changes'); + expect(result.output).toContain('teamai.yaml'); + expect(git( + ['for-each-ref', '--format=%(refname:short)', 'refs/heads/teamai/push/'], + fixture.remote, + )).toBe(''); + } finally { + fs.rmSync(fixture.sandbox, { recursive: true, force: true }); + } + }, 60_000); +}); diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index 11b1aa008..78a4ee001 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { push } from '../push.js'; +import { collectUnsafeDirtyPaths, push } from '../push.js'; import { RolesManifestNotFoundError } from '../roles.js'; import { log } from '../utils/logger.js'; @@ -16,6 +16,33 @@ const mockSaveStateForScope = vi.fn(); const mockLoadRolesManifest = vi.fn(); const mockGetHandler = vi.fn(); +describe('team repo dirty-path guard', () => { + const cleanStatus = { + modified: [], + not_added: [], + created: [], + conflicted: [], + staged: [], + deleted: [], + renamed: [], + }; + + it('allows teamai.yaml only when its content was captured', () => { + expect(collectUnsafeDirtyPaths({ ...cleanStatus, modified: ['teamai.yaml'] }, 'edited')).toEqual([]); + expect(collectUnsafeDirtyPaths({ ...cleanStatus, modified: ['teamai.yaml'] }, null)).toEqual(['teamai.yaml']); + }); + + it('blocks deleted or mode-only teamai.yaml changes while ignoring the sync lock', () => { + expect(collectUnsafeDirtyPaths({ ...cleanStatus, deleted: ['teamai.yaml'] }, null)).toEqual(['teamai.yaml']); + expect(collectUnsafeDirtyPaths({ ...cleanStatus, modified: ['teamai.yaml', '.teamai/.sync-lock'] }, null)) + .toEqual(['teamai.yaml']); + }); + + it('reports ordinary user changes as unsafe', () => { + expect(collectUnsafeDirtyPaths({ ...cleanStatus, not_added: ['README.md'] }, null)).toEqual(['README.md']); + }); +}); + let readlineAnswer = '1'; vi.mock('../utils/prompt.js', () => ({ isInteractive: vi.fn(() => true), @@ -182,6 +209,13 @@ function mockSkillHandler(pushedItems?: Array>) { describe('push namespace routing', () => { beforeEach(() => { vi.clearAllMocks(); + mockGitStatus.mockResolvedValue({ + modified: [], + not_added: [], + created: [], + conflicted: [], + staged: [], + }); mockPullRepo.mockResolvedValue('Already up to date.'); mockPushRepoBranch.mockResolvedValue(true); mockCheckoutMaster.mockResolvedValue(undefined); @@ -607,7 +641,7 @@ it('blocks skills that exist in non-allowed namespaces', async () => { consoleSpy.mockRestore(); }); - it('resets dirty team repo to clean master before pull', async () => { + it('aborts before resetting a dirty team repo', async () => { mockAutoDetectInit.mockResolvedValue({ localConfig: makeLocalConfig({ primaryRole: undefined }), teamConfig: makeTeamConfig(), @@ -615,21 +649,34 @@ it('blocks skills that exist in non-allowed namespaces', async () => { mockSkillHandler(); mockScanTeamRepoNamespaces.mockResolvedValue([]); + const previousExitCode = process.exitCode; + mockGitStatus.mockResolvedValue({ + modified: ['local-edit.txt'], + not_added: [], + created: [], + conflicted: [], + staged: [], + }); + await push({ all: true }); - // Should have called resetToCleanMaster before pull - expect(mockResetToCleanMaster).toHaveBeenCalled(); - expect(mockPullRepo).toHaveBeenCalled(); - // resetToCleanMaster must be called before pullRepo - const resetOrder = mockResetToCleanMaster.mock.invocationCallOrder[0]; - const pullOrder = mockPullRepo.mock.invocationCallOrder[0]; - expect(resetOrder).toBeLessThan(pullOrder); + expect(mockResetToCleanMaster).not.toHaveBeenCalled(); + expect(mockPullRepo).not.toHaveBeenCalled(); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + process.exitCode = previousExitCode; }); }); describe('push item selection', () => { beforeEach(() => { vi.clearAllMocks(); + mockGitStatus.mockResolvedValue({ + modified: [], + not_added: [], + created: [], + conflicted: [], + staged: [], + }); mockPullRepo.mockResolvedValue('Already up to date.'); mockPushRepoBranch.mockResolvedValue(true); mockCheckoutMaster.mockResolvedValue(undefined); diff --git a/src/index.ts b/src/index.ts index 8d860e476..1949e777e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,6 +102,7 @@ program .option('--role ', 'Namespace for new skills, rules and agents (skills//, rules//, agents//)') .option('--project ', "Target a project: each new resource goes to that project's namespace for its own type " + '— skills, knowledge for rules, agents (from manifest/projects.yaml)') + .option('--branch ', 'Push to this destination branch instead of a generated teamai/push branch') .action(async (cmdOpts) => { const globalOpts = program.opts() as GlobalOptions; const { push } = await import('./push.js'); diff --git a/src/push.ts b/src/push.ts index aca83c3a8..24743ca5b 100644 --- a/src/push.ts +++ b/src/push.ts @@ -284,6 +284,57 @@ async function createPrWithFallback( export { createPrWithFallback }; +type PushRepoStatus = { + conflicted?: string[]; + modified?: string[]; + not_added?: string[]; + created?: string[]; + deleted?: string[]; + staged?: string[]; + renamed?: Array; +}; + +/** Return every path that would be at risk before a destructive push reset. */ +function collectDirtyPaths(status: PushRepoStatus): string[] { + const paths = new Set(); + for (const values of [ + status.conflicted, + status.modified, + status.not_added, + status.created, + status.deleted, + status.staged, + ]) { + for (const value of values ?? []) paths.add(value); + } + for (const renamed of status.renamed ?? []) { + if (typeof renamed === 'string') paths.add(renamed); + else { + paths.add(renamed.from); + paths.add(renamed.to); + } + } + return [...paths].sort(); +} + +function isTeamaiOwnedDirtyPath(filePath: string, pendingTeamConfig: string | null): boolean { + const normalized = filePath.replaceAll('\\', '/'); + // The sync lock is disposable TeamAI state. teamai.yaml is different: it is + // safe to restore only when its working-tree content was captured above. + // Deletion and mode-only changes leave pendingTeamConfig null and must stop + // before reset --hard, or the user's change is silently lost (#690 review). + if (normalized === '.teamai/.sync-lock') return true; + return normalized === 'teamai.yaml' && pendingTeamConfig !== null; +} + +export function collectUnsafeDirtyPaths( + status: PushRepoStatus, + pendingTeamConfig: string | null, +): string[] { + return collectDirtyPaths(status) + .filter((filePath) => !isTeamaiOwnedDirtyPath(filePath, pendingTeamConfig)); +} + /** * Outcome of a single {@link pushGroup} call: * - `pushed` — a real push AND a PR obtained (created, or an existing PR reused). @@ -467,8 +518,9 @@ async function pushGroup(args: { localConfig: LocalConfig; pushState: State; includeTeamConfig: boolean; + branch?: string; }): Promise { - const { group, teamConfig, localConfig, pushState, includeTeamConfig } = args; + const { group, teamConfig, localConfig, pushState, includeTeamConfig, branch } = args; const { items, reuse } = group; // pushItem copies files into the team repo's working tree. If any later @@ -519,7 +571,7 @@ async function pushGroup(args: { // sources / publicSkills changes ride along in the same PR as the resources. const configFiles = includeTeamConfig ? ['teamai.yaml'] : []; const gitFiles = [...new Set([...pushedFiles, ...existingSweepers, ...configFiles])]; - const branchName = reuse?.branch ?? generateBranchName(localConfig.username); + const branchName = reuse?.branch ?? branch ?? generateBranchName(localConfig.username); const commitMsg = `[teamai] Push ${items.length} resource(s) from ${localConfig.username}`; // The default-branch commit the branch is built on, which bounds the // history that can prove a placement landed (`reconcilePlacementRecords`). @@ -634,7 +686,7 @@ async function pushGroup(args: { } export async function push( - options: GlobalOptions & { all?: boolean; role?: string; project?: string }, + options: GlobalOptions & { all?: boolean; role?: string; project?: string; branch?: string }, /** * Optional out-param: set to `{ completed: true }` only when a real push * actually happened (resources or config pushed) — never on dry-run, cancel, @@ -762,7 +814,7 @@ export async function push( async function pushCore( localConfig: LocalConfig, teamConfig: TeamaiConfig, - options: GlobalOptions & { all?: boolean; role?: string; project?: string }, + options: GlobalOptions & { all?: boolean; role?: string; project?: string; branch?: string }, initialPendingTeamConfig: string | null = null, result?: { completed: boolean }, ): Promise { @@ -774,15 +826,11 @@ async function pushCore( // - Unmerged (conflicted) files without MERGE_HEAD (incomplete merge) // - Stuck on a stale push branch instead of master // - Uncommitted changes (e.g. votes written by autoUpvote) - // We recover from all of these before pulling. + // We reject dirty repos before pulling so no reset/clean operation can + // silently discard the user's work. // In self mode the worktree is already a fresh detached checkout of // origin/, so resetToCleanMaster/pullRepo (which assume a normal // clone on a branch) are neither needed nor safe — skip them. - // Uncommitted teamai.yaml edits (e.g. from `teamai source add`, which writes the - // file but does not commit) live in the team repo working tree. resetToCleanMaster - // below does `git reset --hard`, which would silently destroy them. Capture the - // working-tree content before the reset and restore it after pull, so config edits - // survive and get committed alongside resources (see gitFiles construction below). let pendingTeamConfig: string | null = initialPendingTeamConfig; // Set when the pull below failed: everything read from the clone after this // point is the previous pull's, manifests included. @@ -811,10 +859,19 @@ async function pushCore( pendingTeamConfig = workingContent; } } + const unsafeDirtyPaths = collectUnsafeDirtyPaths(await git.status(), pendingTeamConfig); + if (unsafeDirtyPaths.length > 0) { + pullSpin.fail( + 'Cannot push: the team repo has uncommitted changes. Commit or stash them first. ' + + `Paths: ${unsafeDirtyPaths.join(', ')}`, + ); + process.exitCode = 1; + return; + } await resetToCleanMaster(git, repoPath); await pullRepo(repoPath); if (pendingTeamConfig !== null) { - // Re-apply the user's config edits on top of the freshly pulled default branch. + // Re-apply the TeamAI-owned config edit after refreshing the default branch. await writeFile(yamlPath, pendingTeamConfig); } pullSpin.succeed('Up to date'); @@ -1418,6 +1475,12 @@ async function pushCore( // can happen in one run, so editing a resource under review updates its PR // without dragging unrelated resources into that review. const groups = planPushGroups(selectedItems, reusablePending); + const newGroupCount = groups.filter((group) => !group.reuse).length; + if (options.branch && newGroupCount > 1) { + log.error('`--branch` can only target one new push branch at a time; select one resource group or omit it.'); + process.exitCode = 2; + return; + } reuseRecordedDestinations(groups); // The conflicting entries dropped above are deliberately not reused, so they // are not "partly selected" either — warning about them would contradict the @@ -1448,6 +1511,7 @@ async function pushCore( localConfig, pushState, includeTeamConfig: configRider, + branch: options.branch, }); if (outcome === 'failed') { // The branch/PR for earlier groups is already on the remote, so their @@ -1502,7 +1566,7 @@ async function pushCore( async function pushTeamConfigOnly( localConfig: LocalConfig, teamConfig: TeamaiConfig, - options: GlobalOptions, + options: GlobalOptions & { branch?: string }, result?: { completed: boolean }, ): Promise { console.log(''); @@ -1516,7 +1580,7 @@ async function pushTeamConfigOnly( } const pushSpin = spinner('Pushing team config...').start(); - const branchName = generateBranchName(localConfig.username); + const branchName = options.branch ?? generateBranchName(localConfig.username); const commitMsg = `[teamai] Update team config from ${localConfig.username}`; try {