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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

```
Expand Down
3 changes: 3 additions & 0 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 会自动检测可用的命名空间并提供交互式选择:

```
Expand Down
1 change: 1 addition & 0 deletions skill-data/core/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Generated: do not edit by hand. Regenerate with
- `--skill <path>` — Push a specific skill by path (e.g., ~/.claude/skills/hai/my-skill or skills/hai_dev/my-skill)
- `--role <id>` — Namespace for new skills, rules and agents (skills/<id>/, rules/<id>/, agents/<id>/)
- `--project <id>` — 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 <name>` — Push to this destination branch instead of a generated teamai/push branch

## pull

Expand Down
5 changes: 4 additions & 1 deletion skill-data/core/references/contribute-member.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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

Expand Down
5 changes: 5 additions & 0 deletions skill-data/setup/references/manage-admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> # push one specific skill
teamai push --branch <name> # 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`).

Expand Down
12 changes: 11 additions & 1 deletion src/__tests__/push-pending-pr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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()]));
Expand Down
135 changes: 126 additions & 9 deletions src/__tests__/push-role-skill-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ async function pullModifyAndPush(
fixture: PushFixture,
agent: PushAgent,
envOverrides: Record<string, string> = {},
extraPushArgs: string[] = [],
): Promise<RunResult> {
const pullResult = await runCLI(
['pull'],
Expand All @@ -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,
Expand Down Expand Up @@ -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<void>((resolve) => server.close(() => resolve()));
fs.rmSync(fixture.sandbox, { recursive: true, force: true });
Expand All @@ -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/'],
Expand Down Expand Up @@ -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);
});
65 changes: 56 additions & 9 deletions src/__tests__/push-role.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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),
Expand Down Expand Up @@ -182,6 +209,13 @@ function mockSkillHandler(pushedItems?: Array<Record<string, unknown>>) {
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);
Expand Down Expand Up @@ -607,29 +641,42 @@ 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(),
});
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);
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ program
.option('--role <id>', 'Namespace for new skills, rules and agents (skills/<id>/, rules/<id>/, agents/<id>/)')
.option('--project <id>', "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 <name>', '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');
Expand Down
Loading
Loading