mirror of
https://github.com/Tencent/teamai-cli.git
synced 2026-10-02 03:14:40 +08:00
feat: opt-in git submodule population on pull (teamai.yaml submodules) (#501)
Teams that distribute skills as git submodules get an empty directory after
clone/fetch - the submodules are never populated, so every resource deploy
silently misses their content.
Add a teamai.yaml knob (off by default):
submodules: true
After each repo refresh (and before the resource deploy step, so freshly
checked-out content is what gets deployed), pull runs
`git submodule update --init`. Deliberately not shallow: submodules are
pinned to exact SHAs, and a shallow fetch only brings the remote tip, so
checking out any older pin fails with "reference is not a tree" - the full
history guarantees the pinned commit is always present.
A failed submodule update does not abort the pull (best-effort warn), but it
also must not persist the new rev: the parent-repo SHA is the
incremental-sync cache key, and caching it while the tree is incomplete
would let the unchanged-rev fast path suppress the retry on every later
pull - tool directories would stay empty forever. refreshTeamRepo therefore
reports submodulesFailed and pullForScope skips the rev persistence for that
run, so the next pull re-runs a full sync and retries the update.
Documented in docs/usage-guide (en + zh-CN), including the auth caveat for
private submodules on token-injecting hosts. Unit tests cover the
submodulesFailed -> rev-not-persisted invariant.
Co-authored-by: flowjzh <flowjzh@users.noreply.github.com>
This commit is contained in:
@@ -1425,6 +1425,25 @@ can turn this off in `teamai.yaml`:
|
||||
usageReport: false
|
||||
```
|
||||
|
||||
### Git submodules
|
||||
|
||||
If your team distributes skills as git submodules, opt in with `submodules: true`
|
||||
in `teamai.yaml`:
|
||||
|
||||
```yaml
|
||||
submodules: true
|
||||
```
|
||||
|
||||
On every pull, teamai runs `git submodule update --init` so submodule-based
|
||||
skills are populated at the revisions pinned by the team repo (git-repo
|
||||
backends only; the full submodule history is fetched, since a shallow fetch
|
||||
cannot check out older pins). Disabled by default. If the update fails, pull
|
||||
logs a warning and holds back the recorded revision, so the next pull
|
||||
re-syncs and retries the update instead of skipping it. Note: submodule
|
||||
fetching relies on the ambient git credentials — private submodules on hosts
|
||||
authenticated by per-command token injection (rather than a configured
|
||||
credential helper) will not authenticate.
|
||||
|
||||
### CI Integration
|
||||
|
||||
`teamai ci extract-mr` plugs into your CI pipeline, automatically extracting knowledge from every MR/PR:
|
||||
|
||||
@@ -1391,6 +1391,21 @@ teamai remove mcp <name>
|
||||
usageReport: false
|
||||
```
|
||||
|
||||
### Git 子模块
|
||||
|
||||
若团队以 git submodule 形式分发 skill,在 `teamai.yaml` 中开启 `submodules: true`:
|
||||
|
||||
```yaml
|
||||
submodules: true
|
||||
```
|
||||
|
||||
每次 pull 时 teamai 会执行 `git submodule update --init`,按团队仓钉住的版本
|
||||
填充子模块(仅 git 仓后端生效;取完整子模块历史——浅取无法检出较旧的 pin)。
|
||||
默认关闭。若更新失败,pull 会记录警告并保留旧的同步版本号,下次 pull 会重新
|
||||
完整同步并自动重试(不会被"版本未变化"的快速路径跳过)。注意:子模块拉取
|
||||
依赖环境现有的 git 凭据——若宿主机采用按命令注入 token 的认证方式(而非配置
|
||||
credential helper),私有子模块将无法通过认证。
|
||||
|
||||
### CI 集成
|
||||
|
||||
`teamai ci extract-mr` 接入 CI 流水线,从每个 MR/PR 自动提取知识:
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock('../config.js', () => ({
|
||||
vi.mock('../utils/git.js', () => ({
|
||||
pullRepo: vi.fn().mockResolvedValue('already up to date'),
|
||||
getHeadRev: vi.fn().mockResolvedValue('abc1234'),
|
||||
createGit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/logger.js', () => ({
|
||||
@@ -74,7 +75,7 @@ vi.mock('../update.js', () => ({
|
||||
|
||||
import { pull, compileRecallRulesBlock } from '../pull.js';
|
||||
import { loadLocalConfigForScope, loadTeamConfig, detectProjectConfig, loadStateForScope, saveStateForScope } from '../config.js';
|
||||
import { getHeadRev } from '../utils/git.js';
|
||||
import { getHeadRev, createGit } from '../utils/git.js';
|
||||
import { log } from '../utils/logger.js';
|
||||
import { TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END } from '../types.js';
|
||||
import type { TeamaiConfig, LocalConfig } from '../types.js';
|
||||
@@ -83,6 +84,7 @@ describe('pull skip-sync when repo HEAD unchanged', () => {
|
||||
let tmpDir: string;
|
||||
let homeDir: string;
|
||||
let repoPath: string;
|
||||
let baseTeamConfig: TeamaiConfig;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-pull-skip-'));
|
||||
@@ -117,6 +119,7 @@ describe('pull skip-sync when repo HEAD unchanged', () => {
|
||||
claude: { skills: '.claude/skills', rules: '.claude/rules' },
|
||||
},
|
||||
};
|
||||
baseTeamConfig = teamConfig;
|
||||
|
||||
const localConfig: LocalConfig = {
|
||||
repo: { localPath: repoPath, remote: 'https://git.woa.com/test/repo.git' },
|
||||
@@ -297,6 +300,58 @@ describe('pull skip-sync when repo HEAD unchanged', () => {
|
||||
expect.stringContaining('Rev check failed'),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not persist the rev when the submodule update failed', async () => {
|
||||
vi.mocked(loadTeamConfig).mockResolvedValue({ ...baseTeamConfig, submodules: true });
|
||||
vi.mocked(createGit).mockReturnValue({
|
||||
submoduleUpdate: vi.fn().mockRejectedValue(new Error('reference is not a tree')),
|
||||
} as unknown as ReturnType<typeof createGit>);
|
||||
vi.mocked(getHeadRev).mockResolvedValue('def5678');
|
||||
vi.mocked(loadStateForScope).mockResolvedValue({
|
||||
lastPull: '2026-04-01',
|
||||
lastPullRev: 'abc1234',
|
||||
lastPush: null,
|
||||
pushedRules: [],
|
||||
pushedSkills: [],
|
||||
pushedEnvVars: [],
|
||||
pendingPushes: [],
|
||||
lastUpdateCheck: null,
|
||||
availableUpdate: null,
|
||||
});
|
||||
|
||||
await pull({});
|
||||
|
||||
// The tree on disk is not a complete snapshot: caching the new rev would
|
||||
// let the unchanged-rev fast path suppress the retry forever.
|
||||
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('Submodule update failed'));
|
||||
expect(saveStateForScope).toHaveBeenCalled();
|
||||
const savedState = vi.mocked(saveStateForScope).mock.calls[0][0];
|
||||
expect(savedState.lastPullRev).toBe('abc1234');
|
||||
});
|
||||
|
||||
it('persists the rev when the submodule update succeeded', async () => {
|
||||
vi.mocked(loadTeamConfig).mockResolvedValue({ ...baseTeamConfig, submodules: true });
|
||||
vi.mocked(createGit).mockReturnValue({
|
||||
submoduleUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ReturnType<typeof createGit>);
|
||||
vi.mocked(getHeadRev).mockResolvedValue('def5678');
|
||||
vi.mocked(loadStateForScope).mockResolvedValue({
|
||||
lastPull: '2026-04-01',
|
||||
lastPullRev: 'abc1234',
|
||||
lastPush: null,
|
||||
pushedRules: [],
|
||||
pushedSkills: [],
|
||||
pushedEnvVars: [],
|
||||
pendingPushes: [],
|
||||
lastUpdateCheck: null,
|
||||
availableUpdate: null,
|
||||
});
|
||||
|
||||
await pull({});
|
||||
|
||||
const savedState = vi.mocked(saveStateForScope).mock.calls[0][0];
|
||||
expect(savedState.lastPullRev).toBe('def5678');
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: a CLI upgrade that ships a new recall block must reach CLAUDE.md
|
||||
|
||||
+48
-17
@@ -1,7 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import matter from 'gray-matter';
|
||||
import { requireInit, loadState, saveState, detectProjectConfig, loadLocalConfigForScope, loadTeamConfig, loadStateForScope, saveStateForScope } from './config.js';
|
||||
import { pullRepo, getHeadRev } from './utils/git.js';
|
||||
import { pullRepo, getHeadRev, createGit } from './utils/git.js';
|
||||
import { flushPendingLearnings } from './utils/pending-learnings.js';
|
||||
import { log, spinner } from './utils/logger.js';
|
||||
import { pathExists, remove, listFiles, listDirs, listFilesRecursive, readFileSafe, dirContentEqual, hasVcsMetadataRecursive } from './utils/fs.js';
|
||||
@@ -59,11 +59,14 @@ interface RolePullContext {
|
||||
*
|
||||
* Returns a display label and the opaque version string used as the
|
||||
* incremental-sync cache key (state.lastPullRev). `version` is null only when
|
||||
* the git backend can't resolve a rev.
|
||||
* the git backend can't resolve a rev. `submodulesFailed` marks a git pull
|
||||
* whose submodule update failed: the caller must then NOT persist the new rev,
|
||||
* or the next pull's unchanged-rev fast path would skip the retry and leave
|
||||
* tool directories pointed at stale/empty submodule content forever.
|
||||
*/
|
||||
async function refreshTeamRepo(
|
||||
localConfig: LocalConfig,
|
||||
): Promise<{ label: string; version: string | null; reportingOnly: boolean }> {
|
||||
): Promise<{ label: string; version: string | null; reportingOnly: boolean; submodulesFailed: boolean }> {
|
||||
if (localConfig.repo.kind === 'http') {
|
||||
const { resolveApiKey } = await import('./api-key.js');
|
||||
const apiKey = resolveApiKey();
|
||||
@@ -72,7 +75,7 @@ async function refreshTeamRepo(
|
||||
}
|
||||
// HTTP backends deliver resources through report/sync (own hook handler),
|
||||
// so there is no repo tree to pull here.
|
||||
return { label: 'HTTP (report/sync delivery)', version: null, reportingOnly: true };
|
||||
return { label: 'HTTP (report/sync delivery)', version: null, reportingOnly: true, submodulesFailed: false };
|
||||
}
|
||||
|
||||
if (localConfig.repo.kind === 'self') {
|
||||
@@ -96,7 +99,7 @@ async function refreshTeamRepo(
|
||||
} catch {
|
||||
version = null;
|
||||
}
|
||||
return { label: 'single-repo (knowledge on main)', version, reportingOnly: false };
|
||||
return { label: 'single-repo (knowledge on main)', version, reportingOnly: false, submodulesFailed: false };
|
||||
}
|
||||
|
||||
// The shared team clone is mutated here (git pull + flushPendingLearnings'
|
||||
@@ -124,7 +127,27 @@ async function refreshTeamRepo(
|
||||
log.debug('Rev check failed, proceeding with full sync');
|
||||
version = null;
|
||||
}
|
||||
return { label: result, version, reportingOnly: false };
|
||||
|
||||
// Skills distributed as git submodules are not populated by clone/fetch.
|
||||
// Opt-in via teamai.yaml `submodules: true`; runs before the resource
|
||||
// deploy step so the freshly checked-out content is what gets deployed.
|
||||
// Deliberately NOT shallow: submodules are pinned to exact SHAs, and a
|
||||
// shallow fetch only brings the remote tip — checking out any older pin
|
||||
// would fail with "reference is not a tree". The full history guarantees
|
||||
// the pinned commit is always present.
|
||||
let submodulesFailed = false;
|
||||
try {
|
||||
const teamConfig = await loadTeamConfig(localConfig.repo.localPath);
|
||||
if (teamConfig?.submodules) {
|
||||
await createGit(localConfig.repo.localPath).submoduleUpdate(['--init']);
|
||||
log.debug('Submodules updated');
|
||||
}
|
||||
} catch (e) {
|
||||
submodulesFailed = true;
|
||||
log.warn(`Submodule update failed for ${localConfig.repo.localPath}: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
return { label: result, version, reportingOnly: false, submodulesFailed };
|
||||
}
|
||||
|
||||
/** teamai.yaml `usageReport: false` — per-repo opt-out of stat commits. */
|
||||
@@ -496,11 +519,15 @@ async function pullForScope(
|
||||
// team-repo-dependent built-in skill (teamai-share-learnings) is useless
|
||||
// there and must not be injected.
|
||||
let reportingOnly = false;
|
||||
// A failed submodule update holds the rev back below so the next pull
|
||||
// retries (see refreshTeamRepo).
|
||||
let submodulesFailed = false;
|
||||
try {
|
||||
const { label, version, reportingOnly: ro } = await refreshTeamRepo(localConfig);
|
||||
currentRev = version;
|
||||
reportingOnly = ro;
|
||||
pullSpin.succeed(`[${scopeLabel}] Team repo: ${label}`);
|
||||
const refresh = await refreshTeamRepo(localConfig);
|
||||
currentRev = refresh.version;
|
||||
reportingOnly = refresh.reportingOnly;
|
||||
submodulesFailed = refresh.submodulesFailed;
|
||||
pullSpin.succeed(`[${scopeLabel}] Team repo: ${refresh.label}`);
|
||||
} catch (e) {
|
||||
pullSpin.fail(`[${scopeLabel}] Pull failed: ${(e as Error).message}`);
|
||||
return;
|
||||
@@ -1040,13 +1067,17 @@ async function pullForScope(
|
||||
if (revisionField === 'lastPullRev') {
|
||||
state.lastPull = new Date().toISOString();
|
||||
}
|
||||
if (currentRev !== null) {
|
||||
state[revisionField] = currentRev;
|
||||
} else {
|
||||
try {
|
||||
state[revisionField] = await getHeadRev(localConfig.repo.localPath);
|
||||
} catch {
|
||||
state[revisionField] = null;
|
||||
// A failed submodule update keeps the previous rev so the next pull
|
||||
// retries the update (see refreshTeamRepo).
|
||||
if (!submodulesFailed) {
|
||||
if (currentRev !== null) {
|
||||
state[revisionField] = currentRev;
|
||||
} else {
|
||||
try {
|
||||
state[revisionField] = await getHeadRev(localConfig.repo.localPath);
|
||||
} catch {
|
||||
state[revisionField] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
state[targetsField] = currentTargets
|
||||
|
||||
@@ -245,6 +245,9 @@ export const TeamaiConfigSchema = z.object({
|
||||
* team repo never receives stat commits (e.g. read-only pull setups).
|
||||
* Default: on. */
|
||||
usageReport: z.boolean().optional(),
|
||||
/** Run `git submodule update --init` on pull so skills distributed as git
|
||||
* submodules are populated and kept current. Off by default. */
|
||||
submodules: z.boolean().optional(),
|
||||
// MCP paths are only set for tools whose config location has been verified.
|
||||
// Tools left without `mcp` are skipped by MCP sync rather than guessed at, so a
|
||||
// wrong guess can never create a junk config file on a user's machine.
|
||||
|
||||
Reference in New Issue
Block a user