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:
flowjzh
2026-09-11 21:47:08 +08:00
committed by GitHub
co-authored by flowjzh
parent e7994db3ca
commit ad5dea5985
5 changed files with 141 additions and 18 deletions
+19
View File
@@ -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:
+15
View File
@@ -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 自动提取知识:
+56 -1
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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.