From 3f85a0bc66e7e9ce822b84b51f46d62b5e5d5ec9 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 10:03:24 +0200 Subject: [PATCH 01/14] refactor(hooks): create project tool roots for a set of tools SessionStart keeps seeding its one tool through seedProjectAgentRoot. The new createProjectToolRoots takes a set of tools, defaulting to enabledAgents and, when that is empty, to the tool roots the main checkout already has. --- src/__tests__/project-agent-root.test.ts | 54 ++++++++++++- src/project-agent-root.ts | 99 +++++++++++++++++++----- 2 files changed, 131 insertions(+), 22 deletions(-) diff --git a/src/__tests__/project-agent-root.test.ts b/src/__tests__/project-agent-root.test.ts index 710d225fa..37d974d11 100644 --- a/src/__tests__/project-agent-root.test.ts +++ b/src/__tests__/project-agent-root.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import YAML from 'yaml'; -import { seedProjectAgentRoot } from '../project-agent-root.js'; +import { createProjectToolRoots, seedProjectAgentRoot } from '../project-agent-root.js'; let tmpDir: string; @@ -130,3 +131,54 @@ describe('seedProjectAgentRoot', () => { expect(fs.existsSync(path.join(projectRoot, '.claude'))).toBe(false); }); }); + +function dotEntries(dir: string): string[] { + return fs.readdirSync(dir).filter((name) => name.startsWith('.')).sort(); +} + +describe('createProjectToolRoots', () => { + it('creates the root of every tool in enabledAgents and nothing else', async () => { + const projectRoot = writeProjectConfig({ enabledAgents: ['claude', 'codex'] }); + + await createProjectToolRoots({ cwd: projectRoot }); + + expect(dotEntries(projectRoot)).toEqual(['.claude', '.codex', '.teamai']); + }); + + describe('in a linked worktree with empty enabledAgents', () => { + function runGit(cwd: string, ...args: string[]): void { + execFileSync('git', args, { cwd, stdio: 'pipe' }); + } + + function linkedWorktree(mainRoots: string[], config: { disabledAgents?: string[] } = {}): string { + const base = fs.realpathSync.native(tmpDir); + const main = path.join(base, 'main'); + fs.mkdirSync(main); + runGit(main, 'init', '-q'); + runGit(main, 'config', 'user.email', 'test@example.com'); + runGit(main, 'config', 'user.name', 'Test'); + runGit(main, 'commit', '--allow-empty', '-q', '-m', 'init'); + for (const root of mainRoots) fs.mkdirSync(path.join(main, root), { recursive: true }); + + const worktree = path.join(base, 'wt'); + runGit(main, 'worktree', 'add', '-q', worktree, 'HEAD'); + return writeProjectConfig({ projectRoot: worktree, ...config }); + } + + it('creates the tool roots the main checkout has', async () => { + const worktree = linkedWorktree(['.claude', '.codex', '.github']); + + await createProjectToolRoots({ cwd: worktree }); + + expect(dotEntries(worktree)).toEqual(['.claude', '.codex', '.git', '.teamai']); + }); + + it('skips a disabled tool even when the main checkout has its root', async () => { + const worktree = linkedWorktree(['.claude', '.cursor'], { disabledAgents: ['cursor'] }); + + await createProjectToolRoots({ cwd: worktree }); + + expect(dotEntries(worktree)).toEqual(['.claude', '.git', '.teamai']); + }); + }); +}); diff --git a/src/project-agent-root.ts b/src/project-agent-root.ts index fc9f2ee76..9392e659d 100644 --- a/src/project-agent-root.ts +++ b/src/project-agent-root.ts @@ -1,9 +1,11 @@ +import { stat } from 'node:fs/promises'; import path from 'node:path'; import { detectProjectConfig, loadTeamConfig } from './config.js'; import { KNOWN_AGENTS } from './known-agents.js'; import { isAgentDisabled, resolveBaseDir, scopedToolPaths, toolInstallRoot } from './types.js'; import { ensureDir } from './utils/fs.js'; +import { resolveAnchors } from './utils/git.js'; import { log } from './utils/logger.js'; /** @@ -33,33 +35,88 @@ function resolveSkillsPath( } /** - * Project-scope SessionStart: create the *current* agent's install root under - * the project (e.g. `/.claude`) so a subsequent `teamai pull` has - * somewhere to write. Bare `teamai pull` / `teamai init` still do not create - * agent roots — only the hook that knows which tool just opened does. + * Project scope: create the install roots of a set of tools under the current + * checkout (e.g. `/.claude`, `/.codex`) so a subsequent + * `teamai pull` has somewhere to write. Bare `teamai pull` does not create + * agent roots; only callers that know which tools the member uses do. * - * No-ops when: not project scope, the tool is disabled / not in enabledAgents, - * the tool is unknown, or the resolved root would escape the project. + * `tools` defaults to `enabledAgents`; when that is empty too, to the tools + * whose root the main checkout already has (a linked worktree then looks like + * the checkout it came from). A tool is skipped when it is disabled, outside a + * non-empty `enabledAgents`, unknown, or its resolved root would escape the + * checkout. No-op outside project scope. */ -export async function seedProjectAgentRoot(tool: string, cwd?: string): Promise { - const id = tool.trim(); - if (!id) return; +export async function createProjectToolRoots(options: { + cwd?: string; + tools?: readonly string[]; +} = {}): Promise { + const requested = options.tools && normalizeIds(options.tools); + if (requested?.length === 0) return; - const projectConfig = await detectProjectConfig(cwd); + const projectConfig = await detectProjectConfig(options.cwd); if (!projectConfig) return; - if (isAgentDisabled(projectConfig, id)) return; - const enabled = projectConfig.enabledAgents; - if (enabled && enabled.length > 0 && !enabled.includes(id)) return; - + const enabled = projectConfig.enabledAgents ?? []; const teamConfig = await loadTeamConfig(projectConfig.repo.localPath); - const skillsPath = resolveSkillsPath(id, teamConfig, projectConfig); - if (!skillsPath) return; + const baseDir = resolveBaseDir(projectConfig); + + const rootOf = (id: string): string | undefined => { + if (isAgentDisabled(projectConfig, id)) return undefined; + if (enabled.length > 0 && !enabled.includes(id)) return undefined; + const skillsPath = resolveSkillsPath(id, teamConfig, projectConfig); + if (!skillsPath) return undefined; + const root = toolInstallRoot(skillsPath); + return isSafeRelativeRoot(root) ? root : undefined; + }; + + const ids = requested + ?? (enabled.length > 0 ? normalizeIds(enabled) : await toolsInMainCheckout(baseDir, teamConfig, rootOf)); + + for (const id of ids) { + const root = rootOf(id); + if (!root) continue; + const dest = path.join(baseDir, root); + await ensureDir(dest); + log.debug(`Seeded project agent root for ${id}: ${dest}`); + } +} + +function normalizeIds(tools: readonly string[]): string[] { + return [...new Set(tools.map((tool) => tool.trim()).filter(Boolean))]; +} - const root = toolInstallRoot(skillsPath); - if (!isSafeRelativeRoot(root)) return; +/** Tools (known or named in teamai.yaml toolPaths) whose root exists in the main checkout of `checkout`. */ +async function toolsInMainCheckout( + checkout: string, + teamConfig: Awaited>, + rootOf: (id: string) => string | undefined, +): Promise { + const mainCheckout = (await resolveAnchors(checkout))?.projectAnchor; + if (!mainCheckout || mainCheckout === checkout) return []; + const candidates = normalizeIds([ + ...KNOWN_AGENTS.map((agent) => agent.id), + ...Object.keys(teamConfig?.toolPaths ?? {}), + ]); + const present: string[] = []; + for (const id of candidates) { + const root = rootOf(id); + if (root && (await isDirectory(path.join(mainCheckout, root)))) present.push(id); + } + return present; +} - const dest = path.join(resolveBaseDir(projectConfig), root); - await ensureDir(dest); - log.debug(`Seeded project agent root for ${id}: ${dest}`); +async function isDirectory(p: string): Promise { + try { + return (await stat(p)).isDirectory(); + } catch { + return false; + } +} + +/** + * Project-scope SessionStart: create the *current* agent's install root, the + * one tool that just opened. See {@link createProjectToolRoots}. + */ +export async function seedProjectAgentRoot(tool: string, cwd?: string): Promise { + await createProjectToolRoots({ cwd, tools: [tool] }); } From b27ff8f865e53b930cc28c42d0937e7da90b498f Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 10:13:37 +0200 Subject: [PATCH 02/14] feat(init): end init with a pull and create the chosen project tool roots Project-scope init with --agent creates those tools' roots after saving enabledAgents, then init runs the scope's pull so the first session has the team's skills, rules and MCP servers. Without --agent no root is created. Docs and setup skill stop telling members to pull after init. --- docs/usage-guide.md | 15 +- docs/usage-guide.zh-CN.md | 12 +- skill-data/core/references/troubleshooting.md | 6 +- skill-data/setup/SKILL.md | 10 +- skill-data/setup/references/join-member.md | 8 +- skill-data/setup/references/setup-admin.md | 8 +- src/__tests__/e2e/init-ends-with-pull.test.ts | 234 ++++++++++++++++++ src/init.ts | 20 +- 8 files changed, 287 insertions(+), 26 deletions(-) create mode 100644 src/__tests__/e2e/init-ends-with-pull.test.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 871991b75..68f15064e 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -162,10 +162,12 @@ there stops if it finds a team rule or skill that differs from the team repo, si cannot tell a teammate's update from your edit. `teamai pull` replaces those files, so copy any you edited somewhere safe, pull, put your edits back and push again. Per-agent project roots (`.claude/`, `.cursor/`, `.codebuddy/`, …) are still created inside the -workspace on **SessionStart** for the tool that just opened. For example, opening -Claude Code creates `.claude/`, then pull writes into it. A bare `teamai pull` still -skips tools whose project root does not exist, so it never invents agent directories -for tools you have not opened in this project. +workspace. `teamai init --agent ` creates the root of each tool it names, then +ends with a pull that fills it. Otherwise **SessionStart** creates the root of the tool +that just opened: opening Claude Code creates `.claude/`, then pull writes into it. A +bare `teamai pull`, and an `init` without `--agent`, still skip tools whose project +root does not exist, so they never invent agent directories for tools you have not +chosen or opened in this project. > **Upgrading from an older teamai?** The first `teamai init` / `pull` / `push` / > `contribute` (or `import --from-mr`) after upgrading automatically migrates an existing `/.teamai/` into the partition @@ -645,7 +647,7 @@ resolve: `teamai skill get team-wiki-codebase` serves `wiki`. ### Auto-sync -`teamai init` already injected Hooks into your AI tools. **`teamai pull` runs automatically every time you start an AI session** — no manual action needed. In project scope, that SessionStart hook first creates the current agent's project root (e.g. `/.claude` when Claude Code opens the repo) if it is missing, then pulls. +`teamai init` already injected Hooks into your AI tools and ended with a pull, so your first session has the team's skills, rules and MCP servers. **`teamai pull` runs automatically every time you start an AI session** — no manual action needed. In project scope, that SessionStart hook first creates the current agent's project root (e.g. `/.claude` when Claude Code opens the repo) if it is missing, then pulls. *(Note: Automatic sync on session start requires an agent that supports lifecycle hooks, such as [CC], Codex, GitHub Copilot CLI, Cursor, CodeBuddy, WorkBuddy, Qoder, Kiro, OpenCode, Oh My Pi, Pi, Hermes, or OpenClaw. Kiro runs the hook when a TeamAI-rendered custom agent is activated in an interactive CLI session; its in-memory built-in default agent is not writable, and non-interactive mode does not fire `agentSpawn`. For tools without a teamai-writable hooks surface such as JoyCode or Gemini CLI, run `teamai pull` manually.)* @@ -2817,7 +2819,6 @@ To rejoin after uninstalling: ```bash teamai init --repo https://github.com/yourorg/yourrepo --scope user --role --force -teamai pull ``` --- @@ -2838,7 +2839,7 @@ teamai init --repo https://github.com/yourorg/yourrepo --force **Q: After `teamai init` in a project, there is no `.claude/` (or `.cursor/`, `.codebuddy/`) directory?** -That is expected for a built-in tool: `init` does not know which agent you will open. Open Claude Code / Cursor / CodeBuddy in the project: the SessionStart hook creates that tool's project root and then pulls. A bare `teamai pull` will not create missing agent roots. The exception is a custom agent defined only in `teamai.yaml`'s `toolPaths` (not one of the built-in tools) — `init --agent ` creates that agent's root itself, since nothing else ever would. This only works for git-backed init (default or `--self`): an HTTP init (`--http`) never clones a local `teamai.yaml`, so it has no custom paths to seed from and only ever creates roots for built-in tools that are already installed. +That is expected for a built-in tool when `init` ran without `--agent`: it does not know which agent you will open. Run `teamai init --agent claude` (or `cursor`, `codebuddy`, …) to create that tool's root and fill it before init exits, or open the tool in the project: the SessionStart hook creates that tool's project root and then pulls. A bare `teamai pull` will not create missing agent roots. The exception is a custom agent defined only in `teamai.yaml`'s `toolPaths` (not one of the built-in tools) — `init --agent ` creates that agent's root itself, since nothing else ever would. This only works for git-backed init (default or `--self`): an HTTP init (`--http`) never clones a local `teamai.yaml`, so it has no custom paths to seed from and only ever creates roots for built-in tools that are already installed. **Q: Hooks aren't firing automatically?** diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 2d3f47adf..923de8bc1 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -157,9 +157,10 @@ teamai init https://github.com/yourorg/yourrepo `teamai pull` 会向它完整同步一次,即使团队仓库自另一个检出 pull 之后并未变化。worktree 尚未 pull 过时, 若 `teamai push` 发现与团队仓库不同的团队 rule 或 skill 就会停止,因为无法区分队友的更新和你的修改。 `teamai pull` 会覆盖这些文件:如有修改,请先另存一份,再在该 worktree 中执行 `teamai pull`,放回修改后重新 push。各 Agent 的项目根目录 -(`.claude/`、`.cursor/`、`.codebuddy/` 等)仍在工作区内、于 **SessionStart** 时按刚打开的 -工具创建。例如,打开 Claude Code 时会创建 `.claude/`,再由 pull 写入。单独执行 `teamai pull` -仍会跳过项目里还不存在根目录的工具,因此不会给尚未在本项目打开过的 Agent 凭空建目录。 +(`.claude/`、`.cursor/`、`.codebuddy/` 等)仍在工作区内创建。`teamai init --agent ` +会为其指定的每个工具创建根目录,并在结束时执行一次 pull 将其填充。否则由 **SessionStart** 按刚打开的 +工具创建:打开 Claude Code 时会创建 `.claude/`,再由 pull 写入。单独执行 `teamai pull`,以及不带 +`--agent` 的 `init`,仍会跳过项目里还不存在根目录的工具,因此不会给尚未在本项目选择或打开过的 Agent 凭空建目录。 > **从旧版 teamai 升级?** 升级后首次执行 `teamai init` / `pull` / `push` / `contribute` > (或 `import --from-mr`)会自动把已有的 @@ -571,7 +572,7 @@ teamai skill path wiki # 打印打包目录,用于运行 skill ### 自动同步 -`teamai init` 时已注入 Hooks 到你的 AI 工具中。**每次启动 AI 会话时会自动执行 `teamai pull`**,无需手动操作。在 project scope 下,该 SessionStart hook 会先为当前 Agent 创建项目根目录(例如用 Claude Code 打开仓库时创建 `/.claude`),然后再 pull。 +`teamai init` 时已注入 Hooks 到你的 AI 工具中,并在结束时执行了一次 pull,因此你的第一个会话就已拥有团队的 skill、rule 和 MCP server。**每次启动 AI 会话时会自动执行 `teamai pull`**,无需手动操作。在 project scope 下,该 SessionStart hook 会先为当前 Agent 创建项目根目录(例如用 Claude Code 打开仓库时创建 `/.claude`),然后再 pull。 *(注:会话启动自动同步依赖工具的生命周期 Hooks 支持,如 [CC]、Codex、GitHub Copilot CLI、Cursor、CodeBuddy、WorkBuddy、Qoder、Kiro、OpenCode、Oh My Pi、Pi、Hermes、OpenClaw 等。Kiro 仅在交互式 CLI 会话激活由 TeamAI 渲染的自定义 agent 时触发该 Hook;其内存中的内置默认 agent 无法写入,非交互模式也不会触发 `agentSpawn`。对于暂无 teamai 可写入 Hooks 的工具(如 JoyCode、Gemini CLI 等),需手动执行 `teamai pull`。)* @@ -2628,7 +2629,6 @@ teamai uninstall --agent claude ```bash teamai init --repo https://github.com/yourorg/yourrepo --scope user --role --force -teamai pull ``` --- @@ -2649,7 +2649,7 @@ teamai init --repo https://github.com/yourorg/yourrepo --force **Q: 在项目里执行 `teamai init` 后没有 `.claude/`(或 `.cursor/`、`.codebuddy/`)目录?** -对内置工具而言这是预期行为:`init` 不知道你会打开哪个 Agent。在项目中打开 Claude Code / Cursor / CodeBuddy:SessionStart hook 会创建该工具的项目根目录并随后 pull。单独执行 `teamai pull` 不会为缺失的 Agent 根目录建目录。例外是仅在 `teamai.yaml` 的 `toolPaths` 中定义的自定义 Agent(不属于内置工具)——`init --agent ` 会自行创建该 Agent 的根目录,因为没有其他流程会为它创建。这仅在 git 模式的 init(默认或 `--self`)下生效:HTTP init(`--http`)不会在本地克隆 `teamai.yaml`,因此没有自定义路径可供创建,只会为已安装的内置工具创建根目录。 +对内置工具而言,`init` 未带 `--agent` 时这是预期行为:它不知道你会打开哪个 Agent。执行 `teamai init --agent claude`(或 `cursor`、`codebuddy` 等)会在 init 结束前创建该工具的根目录并填充;或者在项目中打开该工具:SessionStart hook 会创建该工具的项目根目录并随后 pull。单独执行 `teamai pull` 不会为缺失的 Agent 根目录建目录。例外是仅在 `teamai.yaml` 的 `toolPaths` 中定义的自定义 Agent(不属于内置工具)——`init --agent ` 会自行创建该 Agent 的根目录,因为没有其他流程会为它创建。这仅在 git 模式的 init(默认或 `--self`)下生效:HTTP init(`--http`)不会在本地克隆 `teamai.yaml`,因此没有自定义路径可供创建,只会为已安装的内置工具创建根目录。 **Q: Hooks 没有自动触发?** diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 159c029bb..e9eeac6eb 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -16,8 +16,10 @@ reports before anything else. This is the #1 onboarding issue. In order: -1. **Open a fresh session.** Resources sync on **session start** via a hook, not - at init time. An empty skills folder right after `teamai init` is normal. +1. **Did init pick this tool?** `teamai init` ends with a pull, but a project-scope + init creates only the directories of tools named with `--agent`. Without it, a + tool's project directory appears when that tool opens a session there. Re-run + `teamai init --agent ` to add the tool and fill it now. 2. **Sync manually to confirm:** ```bash teamai pull diff --git a/skill-data/setup/SKILL.md b/skill-data/setup/SKILL.md index 683133311..e0146efc4 100644 --- a/skill-data/setup/SKILL.md +++ b/skill-data/setup/SKILL.md @@ -49,10 +49,12 @@ and create-repo URLs, and the per-provider caveats, and points at auto-start TeamAI, and which detected tools were skipped and why (e.g. Codex trust-gate, CodeBuddy design). Verify the real per-tool result with `teamai doctor` and `teamai hooks list`. -3. **After init, resources appear on the NEXT session.** `teamai init` injects a - session-start hook that auto-runs `teamai pull`. Empty skills/rules directories - right after init are normal; they fill in when the user opens a fresh session in - this tool. To sync immediately, run `teamai pull`. +3. **`teamai init` ends with a pull.** In user scope, and in project scope for each + tool named with `--agent`, the team's skills, rules and MCP servers are in place + when init exits; there is no need to run `teamai pull` after it. A project-scope + init without `--agent` creates no tool directory: a tool's directory appears and + fills when the user opens that tool in the project. Init also injects a + session-start hook that keeps resources synced from then on. 4. **Finish with `teamai doctor`.** Every setup or onboarding flow ends by running it and resolving what it reports before you call the job done. diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index 0107517ab..fdb91d336 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -126,11 +126,13 @@ section "Which tools actually get hooks". ## Step 6 — Confirm the skills actually arrived -Team resources sync on **session start**, so they may be empty right after init. -To confirm now: +`teamai init` ends with a pull, so the team's skills, rules and MCP servers are +already in place in user scope, and in project scope for each tool named with +`--agent`. A project-scope init without `--agent` creates no tool directory; a +tool's directory fills when the user first opens that tool in the project. To +confirm: ```bash -teamai pull # sync immediately teamai list # see the team skills / rules / docs you now have ``` diff --git a/skill-data/setup/references/setup-admin.md b/skill-data/setup/references/setup-admin.md index 6194c71b9..28e7bea03 100644 --- a/skill-data/setup/references/setup-admin.md +++ b/skill-data/setup/references/setup-admin.md @@ -266,9 +266,11 @@ carries counts + tool names only, on a separate branch of that same repo.) URL filled in. The `/teamai` prefix stays as-is; translate the rest: `/teamai Help me join my team's TeamAI, repo URL is ` Tell them to send the URL + this line to each member. -3. Remind them (in their language): **new resources appear only after opening a - fresh session** in the AI tool. Right after init the skills folder may look - empty — that is expected. To sync now, run `teamai pull`. +3. Remind them (in their language): a member's `teamai init` ends with a pull, so + the team's resources are in place when it exits (in project scope, for each + tool named with `--agent`; without it, a tool's directory fills when the + member first opens that tool in the project). Resources the team adds later + arrive at the next session start. ## Step 9 — What's next (guide them, don't just list commands) diff --git a/src/__tests__/e2e/init-ends-with-pull.test.ts b/src/__tests__/e2e/init-ends-with-pull.test.ts new file mode 100644 index 000000000..6032b34ee --- /dev/null +++ b/src/__tests__/e2e/init-ends-with-pull.test.ts @@ -0,0 +1,234 @@ +/** + * E2E: `teamai init` ends with a pull, so the member's first session already + * has the team's skills, rules and MCP servers (sync-before-session, ticket 02). + * + * Claude reads rules and MCP once, at session start, before teamai's + * SessionStart hook can sync. Unless init itself delivers, the first session + * after init runs without them; in project scope nothing at all landed, + * because no tool root existed in the checkout yet. + * + * The team remote is a synthetic HTTPS URL that git's `insteadOf` rewrites to a + * local bare repo in the sandbox HOME (see init-project-all.test.ts), so no + * network is touched. + */ +import { afterAll, 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 FAKE_URL = 'https://git.example.com/team/team.git'; + +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; +} + +let sandbox: string; +let home: string; +let remote: string; +let binDir: string; + +/** + * A `git` ahead of PATH that reports the synthetic HTTPS origin for + * `remote get-url` (so a re-init reuses the clone; real `insteadOf` expansion + * would make it look like another repo), and with TEST_FAIL_FETCH=1 refuses to + * refresh a clone, as an unreachable remote would. + */ +function writeGitWrapper(): void { + binDir = path.join(sandbox, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + const realGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); + fs.writeFileSync(path.join(binDir, 'git'), [ + '#!/bin/sh', + 'if [ "$1" = "remote" ] && [ "$2" = "get-url" ]; then', + ` printf '%s\\n' '${FAKE_URL}'; exit 0`, + 'fi', + 'if [ "$TEST_FAIL_FETCH" = "1" ]; then', + ' for arg in "$@"; do', + ' case "$arg" in', + ' fetch|pull) echo "fatal: unable to access remote (test)" >&2; exit 128 ;;', + ' esac', + ' done', + 'fi', + `exec '${realGit}' "$@"`, + '', + ].join('\n'), { mode: 0o755 }); +} + +function git(args: string[], cwd: string, extraEnv: Record = {}): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, ...GIT_ENV, ...extraEnv }, + }).trim(); +} + +function runCLI(args: string[], cwd: string, extraEnv: Record = {}): Promise { + const env: Record = { + ...process.env, + ...GIT_ENV, + HOME: home, + USERPROFILE: home, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`, + FORCE_COLOR: '0', + GIT_CONFIG_NOSYSTEM: '1', + ...extraEnv, + }; + // The sandbox HOME decides where tools live; a developer's override must not. + delete env.CLAUDE_CONFIG_DIR; + delete env.CODEX_HOME; + return new Promise((resolve) => { + // stdin ignored: no terminal, so init never prompts. + const child = spawn('node', [CLI, ...args], { cwd, env, 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 writeSeed(seed: string, relativePath: string, content: string): void { + const file = path.join(seed, relativePath); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +/** A git business repo with no tool roots of its own. */ +function makeBusinessRepo(name: string): string { + const dir = path.join(sandbox, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'README.md'), '# app\n'); + git(['init', '-q', '-b', 'main'], dir); + git(['add', '-A'], dir); + git(['commit', '-q', '-m', 'app'], dir); + return dir; +} + +const TOOL_ROOTS = ['.claude', '.codex', '.cursor', '.codebuddy', '.opencode', '.config']; + +describe.skipIf(process.platform === 'win32')('teamai init ends with a pull', () => { + beforeAll(() => { + if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + sandbox = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-init-pull-e2e-'))); + remote = path.join(sandbox, 'team.git'); + const seed = path.join(sandbox, 'seed'); + + writeSeed(seed, 'teamai.yaml', [ + 'team: init-pull-e2e', + `repo: ${FAKE_URL}`, + 'provider: git', + 'reviewers: []', + 'sharing:', + ' mcp:', + ' autoApply: true', + '', + ].join('\n')); + writeSeed(seed, 'skills/team-skill/SKILL.md', + '---\nname: team-skill\ndescription: Team skill fixture\n---\n\n# Team skill\n'); + writeSeed(seed, 'rules/team-rule.md', '# Team rule\n'); + writeSeed(seed, 'mcp/mcp.yaml', [ + 'servers:', + ' - name: team-api', + ' transport: http', + ' url: https://team.example.com/mcp', + '', + ].join('\n')); + git(['init', '-q', '-b', 'main'], seed); + git(['add', '-A'], seed); + git(['commit', '-q', '-m', 'seed'], seed); + git(['clone', '-q', '--bare', seed, remote], sandbox); + writeGitWrapper(); + }); + + beforeEach(() => { + home = path.join(sandbox, `home-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(home, { recursive: true }); + // Claude and Codex are installed on this machine. + fs.mkdirSync(path.join(home, '.claude'), { recursive: true }); + fs.mkdirSync(path.join(home, '.codex'), { recursive: true }); + git(['config', '--global', `url.${remote}.insteadOf`, FAKE_URL], sandbox, { HOME: home, USERPROFILE: home }); + }); + + afterAll(() => { + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it('user scope: the team skill, rule and MCP server are delivered when init exits', async () => { + const cwd = path.join(sandbox, 'elsewhere'); + fs.mkdirSync(cwd, { recursive: true }); + const result = await runCLI(['init', FAKE_URL, '--scope', 'user', '--agent', 'claude', '--force'], cwd); + expect(result.code, result.output).toBe(0); + expect(result.output).toMatch(/teamai initialized successfully/); + + expect(fs.existsSync(path.join(home, '.claude', 'skills', 'team-skill', 'SKILL.md')), result.output).toBe(true); + expect(fs.readFileSync(path.join(home, '.claude', 'rules', 'team-rule.md'), 'utf8')).toContain('Team rule'); + const claudeJson = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf8')) as { + mcpServers?: Record; + }; + expect(Object.keys(claudeJson.mcpServers ?? {})).toContain('team-api'); + }, 90_000); + + it('project scope with --agent claude: .claude/ is created and filled, .mcp.json holds the team server', async () => { + const project = makeBusinessRepo(`app-${Math.random().toString(36).slice(2)}`); + const result = await runCLI(['init', FAKE_URL, '--scope', 'project', '--agent', 'claude', '--force'], project); + expect(result.code, result.output).toBe(0); + expect(result.output).toMatch(/teamai initialized successfully/); + + expect(fs.existsSync(path.join(project, '.claude', 'skills', 'team-skill', 'SKILL.md')), result.output).toBe(true); + expect(fs.readFileSync(path.join(project, '.claude', 'rules', 'team-rule.md'), 'utf8')).toContain('Team rule'); + const mcp = JSON.parse(fs.readFileSync(path.join(project, '.mcp.json'), 'utf8')) as { + mcpServers?: Record; + }; + expect(Object.keys(mcp.mcpServers ?? {})).toContain('team-api'); + // Only the chosen tool's root, although Codex is installed too. + expect(fs.existsSync(path.join(project, '.codex'))).toBe(false); + }, 90_000); + + it('project scope re-init with another --agent adds that root even if enabledAgents was narrower', async () => { + const project = makeBusinessRepo(`app-${Math.random().toString(36).slice(2)}`); + const first = await runCLI(['init', FAKE_URL, '--scope', 'project', '--agent', 'codex', '--force'], project); + expect(first.code, first.output).toBe(0); + expect(fs.existsSync(path.join(project, '.claude'))).toBe(false); + + const second = await runCLI(['init', FAKE_URL, '--scope', 'project', '--agent', 'claude', '--force'], project); + expect(second.code, second.output).toBe(0); + expect(fs.existsSync(path.join(project, '.claude', 'skills', 'team-skill', 'SKILL.md')), second.output).toBe(true); + }, 120_000); + + it('project scope without a terminal and without --agent: no tool root is created', async () => { + const project = makeBusinessRepo(`app-${Math.random().toString(36).slice(2)}`); + const result = await runCLI(['init', FAKE_URL, '--scope', 'project', '--force'], project); + expect(result.code, result.output).toBe(0); + expect(result.output).toMatch(/teamai initialized successfully/); + for (const root of TOOL_ROOTS) { + expect(fs.existsSync(path.join(project, root)), `${root} was created:\n${result.output}`).toBe(false); + } + }, 90_000); + + it('a pull failure inside init is reported the way pull reports it, and init still succeeds', async () => { + // init's own clone works; the pull that follows cannot refresh the clone. + const cwd = path.join(sandbox, 'elsewhere'); + fs.mkdirSync(cwd, { recursive: true }); + const result = await runCLI( + ['init', FAKE_URL, '--scope', 'user', '--agent', 'claude', '--force'], + cwd, + { TEST_FAIL_FETCH: '1' }, + ); + expect(result.output).toMatch(/\[user\] Pull failed:/); + expect(result.output).toMatch(/teamai initialized successfully/); + expect(result.code, result.output).toBe(0); + }, 90_000); +}); diff --git a/src/init.ts b/src/init.ts index 26d9ba803..5794cff5e 100644 --- a/src/init.ts +++ b/src/init.ts @@ -2055,6 +2055,16 @@ export async function init(options: GlobalOptions & { // Non-critical: state file may not exist yet on first init } + // Step 6.6: project scope creates the roots of the tools the member chose + // (`--agent`), so the stub below and the closing pull have somewhere to + // write. Runs after the config is saved: createProjectToolRoots filters by + // the saved enabledAgents, which now includes this run's choice. Without + // `--agent` no root is invented, as before. + if (scope === 'project' && requestedAgents.length > 0) { + const { createProjectToolRoots } = await import('./project-agent-root.js'); + await createProjectToolRoots({ cwd: projectRoot, tools: requestedAgents }); + } + // Step 7: Inject built-in + team hooks into AI tools const reloadedTeamConfig = await loadTeamConfig(localPath); // Only a stub that actually landed is announced as ready in the IDE. @@ -2071,7 +2081,8 @@ export async function init(options: GlobalOptions & { // installed" (#867). Built-in tools are left untouched here: their root // already existing is exactly what doctor's "is installed" check verifies // (#598), so seeding them outside self mode would silently manufacture a - // directory for software that was never actually installed. + // directory for software that was never actually installed. Only the + // project roots of tools named with `--agent` are created (Step 6.6). try { const { seedSelfModeToolDirs } = await import('./known-agents.js'); const seeded = await seedSelfModeToolDirs(localConfig, reloadedTeamConfig); @@ -2096,6 +2107,13 @@ export async function init(options: GlobalOptions & { } } + // Step 8: deliver the team's resources now. Rules and MCP are read once at + // session start, before the SessionStart hook syncs, so without this the + // first session after init runs without them. Failures are reported by pull + // in its own words; init itself has succeeded. + const { pull } = await import('./pull.js'); + await pull({ verbose: options.verbose, interactive: true }); + log.success('teamai initialized successfully!'); if (stubDeployed > 0) { log.info('The built-in teamai skill is ready in your IDE; it loads its workflows with `teamai skill get`.'); From 4e76550556bf10f914a263ab53a6b8d05bf50674 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 10:41:43 +0200 Subject: [PATCH 03/14] feat(hooks): sync resources into new worktrees via a git hook --- docs/usage-guide.md | 15 + docs/usage-guide.zh-CN.md | 11 + skill-data/core/SKILL.md | 9 + .../e2e/git-hook-new-worktree.test.ts | 299 ++++++++++++++++++ src/__tests__/git-hook.test.ts | 111 +++++++ src/__tests__/hook-handlers.test.ts | 7 +- src/builtin-hooks.ts | 2 +- src/git-hook.ts | 125 ++++++++ src/hook-dispatch-cli.ts | 18 +- src/hook-handlers.ts | 43 +++ src/hooks.ts | 19 ++ src/index.ts | 11 +- 12 files changed, 660 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/e2e/git-hook-new-worktree.test.ts create mode 100644 src/__tests__/git-hook.test.ts create mode 100644 src/git-hook.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 68f15064e..42d9d3d90 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -169,6 +169,21 @@ bare `teamai pull`, and an `init` without `--agent`, still skip tools whose proj root does not exist, so they never invent agent directories for tools you have not chosen or opened in this project. +A new worktree does not wait for that first session. In project scope, `teamai init` +and `teamai pull` install a git hook in the repository's local git config, shared by +every worktree: `hook.teamai-post-checkout` and `hook.teamai-post-merge` (Git 2.54 or +later; with older Git none is installed). Git runs it beside any `core.hooksPath` hook +manager and any `.git/hooks` script. When `git worktree add`, or an app that creates +worktrees, makes a new checkout, the hook creates the project roots of +`enabledAgents` (when that is empty, the roots the main checkout has) and pulls into +the worktree before the command returns, so the first session there already has the +team's skills, rules and MCP servers. A branch switch does nothing, and `post-merge` +does nothing yet. The hook prints nothing and always exits 0, so a failed pull never +fails the git command. It follows the scope rules below: no project config, or one +that cannot be read, means no sync. The command is one `sh` line that runs +`teamai hook-dispatch --tool git` with Git's arguments, finding `teamai` +through `~/.teamai/bin` as the agent hooks do. + > **Upgrading from an older teamai?** The first `teamai init` / `pull` / `push` / > `contribute` (or `import --from-mr`) after upgrading automatically migrates an existing `/.teamai/` into the partition > (copy → verify → atomic switch), then leaves the old directory as `/.teamai.bak/` diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 923de8bc1..3e7c50039 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -162,6 +162,17 @@ teamai init https://github.com/yourorg/yourrepo 工具创建:打开 Claude Code 时会创建 `.claude/`,再由 pull 写入。单独执行 `teamai pull`,以及不带 `--agent` 的 `init`,仍会跳过项目里还不存在根目录的工具,因此不会给尚未在本项目选择或打开过的 Agent 凭空建目录。 +新 worktree 不必等到第一次会话。在项目 scope 下,`teamai init` 与 `teamai pull` 会在仓库的本地 +git 配置中安装一个 git hook,所有 worktree 共用:`hook.teamai-post-checkout` 与 +`hook.teamai-post-merge`(需要 Git 2.54 或更高版本;更旧的 Git 不会安装)。Git 会在任何 +`core.hooksPath` hook 管理器和 `.git/hooks` 脚本之外一并运行它。当 `git worktree add`,或会创建 +worktree 的应用,新建一个检出时,该 hook 会创建 `enabledAgents` 的项目根目录(为空时,取主检出已有的根目录), +并在命令返回前向该 worktree 执行 pull,因此其中的第一次会话就已具备团队的 skill、rule 与 MCP 服务器。 +切换分支不会触发任何操作,`post-merge` 目前也不做任何事。该 hook 不输出任何内容且始终以 0 退出, +因此 pull 失败也不会让 git 命令失败。它遵循下文的 scope 规则:没有项目配置,或项目配置无法读取, +都不会同步。其命令是一行 `sh`,带着 Git 传入的参数运行 `teamai hook-dispatch --tool git`, +与 Agent hook 一样通过 `~/.teamai/bin` 找到 `teamai`。 + > **从旧版 teamai 升级?** 升级后首次执行 `teamai init` / `pull` / `push` / `contribute` > (或 `import --from-mr`)会自动把已有的 > `/.teamai/` 迁移进分区(复制 → 校验 → 原子切换),并把旧目录保留为 diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 02ba9a7d3..6553acfa7 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -127,6 +127,15 @@ changed since, or push says so for that copy, merge that change into the copy fi the copy and run `teamai pull --force`. The first pull after upgrading, and a new worktree's first pull, still overwrite: nothing is recorded yet. +In project scope, `init` and `pull` also install a git hook in the repository's +local git config (`hook.teamai-post-checkout`, `hook.teamai-post-merge`; Git +2.54+), beside any `core.hooksPath` manager or `.git/hooks` script. When a +worktree is created (`git worktree add`, or an app), it creates the project roots +of `enabledAgents` (else the ones the main checkout has) and pulls into it before +the command returns. A branch switch does nothing. It prints nothing and always +exits 0, so a worktree still missing team resources needs a `teamai pull` there; +`git config --local --get-regexp '^hook\.teamai-'` shows whether it is installed. + A team agent (`agents/.yaml`) can set `model: strong`, `model: fast`, or an alias the team defines, instead of one tool's model. The team maps each alias per tool in `models/aliases.yaml`, in that tool's own model value, with an optional effort: diff --git a/src/__tests__/e2e/git-hook-new-worktree.test.ts b/src/__tests__/e2e/git-hook-new-worktree.test.ts new file mode 100644 index 000000000..192499c88 --- /dev/null +++ b/src/__tests__/e2e/git-hook-new-worktree.test.ts @@ -0,0 +1,299 @@ +/** + * E2E: a new worktree gets the team's resources before `git worktree add` + * returns. `teamai init` (project scope) installs a named hook in the + * repository's git config; real git runs it on `post-checkout`, and it calls + * the real CLI's dispatcher, which creates the tool roots and pulls into the + * new worktree. + * + * The team remote is a local bare repo reached through a synthetic HTTPS URL + * (`url..insteadOf` in the sandbox HOME), as in init-project-all.test.ts. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync, spawnSync } 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 FAKE_URL = 'https://git.example.com/team/hook-team.git'; +const ZERO_OID = '0'.repeat(40); + +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', +}; + +const configHooks = (() => { + const m = /(\d+)\.(\d+)/.exec(execFileSync('git', ['--version'], { encoding: 'utf8' })); + const [major, minor] = m ? [Number(m[1]), Number(m[2])] : [0, 0]; + return major > 2 || (major === 2 && minor >= 54); +})(); + +interface Run { + code: number | null; + output: string; +} + +describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resources (git worktree add)', () => { + let sandbox: string; + let home: string; + let remote: string; + let claudeProject: string; + + const env = (extra: Record = {}): NodeJS.ProcessEnv => { + const base: NodeJS.ProcessEnv = { ...process.env, ...GIT_ENV, HOME: home, USERPROFILE: home, FORCE_COLOR: '0', ...extra }; + delete base.CLAUDE_CONFIG_DIR; + delete base.CODEX_HOME; + return base; + }; + + const run = (command: string, args: string[], cwd: string, extra: Record = {}): Run => { + const r = spawnSync(command, args, { cwd, encoding: 'utf8', env: env(extra) }); + return { code: r.status, output: `${r.stdout ?? ''}${r.stderr ?? ''}` }; + }; + const git = (args: string[], cwd: string, extra: Record = {}): Run => run('git', args, cwd, extra); + const gitOk = (args: string[], cwd: string): string => { + const r = git(args, cwd); + if (r.code !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.output}`); + return r.output.trim(); + }; + const teamai = (args: string[], cwd: string, extra: Record = {}): Run => run('node', [CLI, ...args], cwd, extra); + + /** A business repo with one commit, `teamai init`-ed in project scope. */ + const project = (name: string, initArgs: string[]): string => { + const dir = path.join(sandbox, name); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, '.gitignore'), '.teamai/\n'); + gitOk(['init', '-q', '-b', 'main'], dir); + gitOk(['add', '-A'], dir); + gitOk(['commit', '-q', '-m', 'project'], dir); + const init = teamai(['init', FAKE_URL, '--scope', 'project', '--force', ...initArgs], dir); + if (init.code !== 0) throw new Error(`teamai init failed: ${init.output}`); + return dir; + }; + + const worktreeAdd = (repo: string, name: string, extra: Record = {}): { dir: string } & Run => { + const dir = path.join(sandbox, name); + return { dir, ...git(['worktree', 'add', '-q', dir], repo, extra) }; + }; + + const delivered = (dir: string) => ({ + skill: fs.existsSync(path.join(dir, '.claude', 'skills', 'team-skill', 'SKILL.md')), + rule: fs.existsSync(path.join(dir, '.claude', 'rules', 'team-rule.md')), + mcp: fs.existsSync(path.join(dir, '.mcp.json')) && fs.readFileSync(path.join(dir, '.mcp.json'), 'utf8').includes('team-api'), + }); + const ALL = { skill: true, rule: true, mcp: true }; + const NOTHING = { skill: false, rule: false, mcp: false }; + + /** The project partition (data home) `init` created for the repo at `root`. */ + const partitionOf = (root: string): string => { + const projectsDir = path.join(home, '.teamai', 'projects'); + const found = fs.readdirSync(projectsDir).map((d) => path.join(projectsDir, d)).find((d) => { + const config = path.join(d, 'config.yaml'); + return fs.existsSync(config) && fs.readFileSync(config, 'utf8').includes(`projectRoot: ${root}`); + }); + if (!found) throw new Error(`No project partition for ${root}`); + return found; + }; + + /** Entries the worktree has beyond what the branch tracks. */ + const untracked = (dir: string) => + fs.readdirSync(dir).filter((entry) => entry !== '.git' && entry !== '.gitignore').sort(); + + beforeAll(() => { + if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + + sandbox = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-git-hook-e2e-'))); + home = path.join(sandbox, 'home'); + remote = path.join(sandbox, 'team.git'); + const seed = path.join(sandbox, 'seed'); + fs.mkdirSync(home); + const write = (rel: string, content: string) => { + fs.mkdirSync(path.dirname(path.join(seed, rel)), { recursive: true }); + fs.writeFileSync(path.join(seed, rel), content); + }; + write('teamai.yaml', `team: git-hook-e2e\nrepo: ${FAKE_URL}\nprovider: git\nreviewers: []\nsharing:\n mcp:\n autoApply: true\n`); + write('skills/team-skill/SKILL.md', '---\nname: team-skill\ndescription: Team skill fixture\n---\n\n# Team skill\n'); + write('rules/team-rule.md', '# Team rule\n'); + write('mcp/mcp.yaml', 'servers:\n - name: team-api\n transport: http\n url: https://team.example.com/mcp\n'); + gitOk(['init', '-q', '-b', 'main'], seed); + gitOk(['add', '-A'], seed); + gitOk(['commit', '-q', '-m', 'seed'], seed); + gitOk(['clone', '-q', '--bare', seed, remote], sandbox); + gitOk(['config', '--global', `url.${remote}.insteadOf`, FAKE_URL], sandbox); + + claudeProject = project('claude-project', ['--agent', 'claude']); + }, 60_000); + + afterAll(() => { + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it('init installs one named hook per git event in the repository config', () => { + expect(gitOk(['hook', 'list', 'post-checkout'], claudeProject)).toBe('teamai-post-checkout'); + expect(gitOk(['hook', 'list', 'post-merge'], claudeProject)).toBe('teamai-post-merge'); + }); + + it('delivers skills, rules and MCP for enabledAgents before git worktree add returns, silently', () => { + const wt = worktreeAdd(claudeProject, 'wt-claude'); + + expect(wt.code).toBe(0); + expect(wt.output).toBe(''); + expect(delivered(wt.dir)).toEqual(ALL); + expect(fs.existsSync(path.join(wt.dir, '.codex'))).toBe(false); + }); + + it('does the same for a worktree an app creates from a script, without a login PATH or a session', () => { + // Only git on PATH: teamai is found through the wrapper in ~/.teamai/bin. + const onlyGit = path.join(sandbox, 'only-git'); + fs.mkdirSync(onlyGit, { recursive: true }); + const realGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); + if (!fs.existsSync(path.join(onlyGit, 'git'))) fs.symlinkSync(realGit, path.join(onlyGit, 'git')); + const dir = path.join(sandbox, 'wt-app'); + const script = path.join(sandbox, 'app.sh'); + fs.writeFileSync(script, `#!/bin/sh\ncd "$1" && git worktree add -q "$2"\n`, { mode: 0o755 }); + + const r = run('/bin/sh', [script, claudeProject, dir], sandbox, { PATH: `${onlyGit}:/usr/bin:/bin` }); + + expect(r.code, r.output).toBe(0); + expect(r.output).toBe(''); + expect(delivered(dir)).toEqual(ALL); + }); + + it('a branch switch in an existing checkout triggers no sync', () => { + const wt = worktreeAdd(claudeProject, 'wt-switch'); + expect(delivered(wt.dir)).toEqual(ALL); + fs.rmSync(path.join(wt.dir, '.claude'), { recursive: true, force: true }); + + const r = git(['checkout', '-q', '-b', 'feature-switch'], wt.dir); + + expect(r.code, r.output).toBe(0); + expect(r.output).toBe(''); + expect(fs.existsSync(path.join(wt.dir, '.claude'))).toBe(false); + }); + + it('runs beside an existing .git/hooks/post-checkout script', () => { + const marker = path.join(sandbox, 'hooks-dir-ran'); + const hookFile = path.join(claudeProject, '.git', 'hooks', 'post-checkout'); + fs.writeFileSync(hookFile, `#!/bin/sh\necho "$1" > "${marker}"\n`, { mode: 0o755 }); + try { + const wt = worktreeAdd(claudeProject, 'wt-hooks-dir'); + + expect(wt.code, wt.output).toBe(0); + expect(fs.readFileSync(marker, 'utf8').trim()).toBe(ZERO_OID); + expect(delivered(wt.dir)).toEqual(ALL); + } finally { + fs.rmSync(hookFile, { force: true }); + } + }); + + it('runs beside a core.hooksPath hook manager', () => { + const marker = path.join(sandbox, 'hooks-path-ran'); + const managerDir = path.join(sandbox, 'manager-hooks'); + fs.mkdirSync(managerDir, { recursive: true }); + fs.writeFileSync(path.join(managerDir, 'post-checkout'), `#!/bin/sh\necho "$1" > "${marker}"\n`, { mode: 0o755 }); + gitOk(['config', '--local', 'core.hooksPath', managerDir], claudeProject); + try { + const wt = worktreeAdd(claudeProject, 'wt-hooks-path'); + + expect(wt.code, wt.output).toBe(0); + expect(fs.readFileSync(marker, 'utf8').trim()).toBe(ZERO_OID); + expect(delivered(wt.dir)).toEqual(ALL); + } finally { + gitOk(['config', '--local', '--unset', 'core.hooksPath'], claudeProject); + } + }); + + it('prints nothing and exits 0 when the pull fails', () => { + const away = `${remote}.away`; + fs.renameSync(remote, away); + try { + const wt = worktreeAdd(claudeProject, 'wt-offline'); + + expect(wt.code).toBe(0); + expect(wt.output).toBe(''); + expect(fs.existsSync(path.join(wt.dir, '.gitignore'))).toBe(true); + } finally { + fs.renameSync(away, remote); + } + }); + + it('an unreadable project config means no sync', () => { + const config = path.join(partitionOf(claudeProject), 'config.yaml'); + const original = fs.readFileSync(config, 'utf8'); + fs.writeFileSync(config, 'repo: [unclosed\n'); + try { + const wt = worktreeAdd(claudeProject, 'wt-unreadable'); + + expect(wt.code).toBe(0); + expect(wt.output).toBe(''); + expect(fs.existsSync(path.join(wt.dir, '.claude'))).toBe(false); + } finally { + fs.writeFileSync(config, original); + } + }); + + it('a repository without teamai config does nothing with a hook left behind', () => { + const other = path.join(sandbox, 'no-teamai'); + fs.mkdirSync(other); + gitOk(['init', '-q', '-b', 'main'], other); + gitOk(['commit', '-q', '--allow-empty', '-m', 'init'], other); + gitOk(['config', '--local', 'hook.teamai-post-checkout.command', + gitOk(['config', '--local', '--get', 'hook.teamai-post-checkout.command'], claudeProject)], other); + gitOk(['config', '--local', 'hook.teamai-post-checkout.event', 'post-checkout'], other); + fs.mkdirSync(path.join(other, '.claude')); + + const wt = worktreeAdd(other, 'wt-no-teamai'); + + expect(wt.code).toBe(0); + expect(wt.output).toBe(''); + expect(untracked(wt.dir)).toEqual([]); + }); + + it('a worktree teamai creates under its own data home is left alone', () => { + const dir = path.join(partitionOf(claudeProject), 'own-worktree'); + + const r = git(['worktree', 'add', '-q', '--detach', dir], claudeProject); + + expect(r.code, r.output).toBe(0); + expect(fs.existsSync(path.join(dir, '.claude'))).toBe(false); + }); + + it('clears the repository Git exports to the hook before running git', () => { + // A worktree created with the hook off, then the dispatcher run the way git + // runs it for `git --git-dir=... worktree add`: GIT_DIR names the business + // repo, and the team clone's pull must not act on it. + const dir = path.join(sandbox, 'wt-git-dir'); + gitOk(['-c', 'hook.teamai-post-checkout.enabled=false', 'worktree', 'add', '-q', dir], claudeProject); + expect(delivered(dir)).toEqual(NOTHING); + const head = gitOk(['rev-parse', 'HEAD'], dir); + + const r = teamai(['hook-dispatch', 'post-checkout', '--tool', 'git', ZERO_OID, head, '1'], dir, { + GIT_DIR: path.join(claudeProject, '.git'), + GIT_WORK_TREE: claudeProject, + }); + + expect(r.code).toBe(0); + expect(r.output).toBe(''); + expect(delivered(dir)).toEqual(ALL); + }); + + it('with no enabledAgents, creates the tool roots the main checkout has, and only those', () => { + const codexProject = project('codex-project', []); + fs.mkdirSync(path.join(codexProject, '.codex')); + + const wt = worktreeAdd(codexProject, 'wt-codex'); + + expect(wt.code).toBe(0); + expect(wt.output).toBe(''); + expect(fs.statSync(path.join(wt.dir, '.codex')).isDirectory()).toBe(true); + expect(untracked(wt.dir).filter((entry) => entry.startsWith('.') && fs.statSync(path.join(wt.dir, entry)).isDirectory())) + .toEqual(['.codex']); + }); +}); diff --git a/src/__tests__/git-hook.test.ts b/src/__tests__/git-hook.test.ts new file mode 100644 index 000000000..d063ebf43 --- /dev/null +++ b/src/__tests__/git-hook.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { execFileSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { installGitHook } from '../git-hook.js'; + +const gitVersion = (): [number, number] => { + const m = /(\d+)\.(\d+)/.exec(execFileSync('git', ['--version'], { encoding: 'utf8' })); + return m ? [Number(m[1]), Number(m[2])] : [0, 0]; +}; +const [major, minor] = gitVersion(); +const configHooks = major > 2 || (major === 2 && minor >= 54); + +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', +}; + +describe.skipIf(!configHooks)('teamai git hook in the repository config', () => { + let sandbox: string; + let repo: string; + let home: string; + + const git = (args: string[], cwd = repo) => + spawnSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, ...GIT_ENV, HOME: home } }); + + /** A `teamai` on the wrapper path that records its arguments, then exits with `code`. */ + const fakeTeamai = (code: number) => { + const bin = path.join(home, '.teamai', 'bin'); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync( + path.join(bin, 'teamai'), + `#!/bin/sh\necho "$@" >> "${path.join(sandbox, 'calls.txt')}"\necho noise\necho noise >&2\nexit ${code}\n`, + { mode: 0o755 }, + ); + }; + const calls = () => { + const file = path.join(sandbox, 'calls.txt'); + return fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim().split('\n') : []; + }; + + beforeEach(() => { + sandbox = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-git-hook-'))); + repo = path.join(sandbox, 'repo'); + home = path.join(sandbox, 'home'); + fs.mkdirSync(repo); + fs.mkdirSync(home); + git(['init', '-q', '-b', 'main']); + git(['commit', '-q', '--allow-empty', '-m', 'init']); + }); + + afterEach(() => { + fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it('registers one named hook per event, shared by every worktree', async () => { + await installGitHook(repo); + + expect(git(['hook', 'list', 'post-checkout']).stdout.trim()).toBe('teamai-post-checkout'); + expect(git(['hook', 'list', 'post-merge']).stdout.trim()).toBe('teamai-post-merge'); + // Written to the common config: a linked worktree sees the same hooks. + git(['worktree', 'add', '-q', path.join(sandbox, 'wt')]); + expect(git(['hook', 'list', 'post-checkout'], path.join(sandbox, 'wt')).stdout.trim()).toBe('teamai-post-checkout'); + }); + + it('is idempotent', async () => { + await installGitHook(repo); + await installGitHook(repo); + + expect(git(['config', '--local', '--get-all', 'hook.teamai-post-checkout.event']).stdout.trim()).toBe('post-checkout'); + expect(git(['config', '--local', '--get-all', 'hook.teamai-post-merge.event']).stdout.trim()).toBe('post-merge'); + }); + + it('passes the event and Git\'s arguments to the dispatcher, silently', async () => { + await installGitHook(repo); + fakeTeamai(0); + + const r = git(['hook', 'run', 'post-checkout', '--', 'old', 'new', '1']); + + expect(r.status).toBe(0); + expect(r.stdout + r.stderr).toBe(''); + expect(calls()).toEqual(['hook-dispatch post-checkout --tool git old new 1']); + }); + + it('exits 0 and prints nothing when the dispatcher fails or teamai is missing', async () => { + await installGitHook(repo); + fakeTeamai(3); + const failing = git(['hook', 'run', 'post-merge', '--', '0']); + expect(failing.status).toBe(0); + expect(failing.stdout + failing.stderr).toBe(''); + expect(calls()).toEqual(['hook-dispatch post-merge --tool git 0']); + + // Only git on PATH, so no globally installed teamai can answer. + fs.rmSync(path.join(home, '.teamai'), { recursive: true, force: true }); + const onlyGit = path.join(sandbox, 'only-git'); + fs.mkdirSync(onlyGit); + const realGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); + fs.symlinkSync(realGit, path.join(onlyGit, 'git')); + const missing = spawnSync(realGit, ['hook', 'run', 'post-merge', '--', '0'], { + cwd: repo, + encoding: 'utf8', + env: { ...process.env, HOME: home, PATH: `${onlyGit}:/usr/bin:/bin` }, + }); + expect(missing.status).toBe(0); + expect(missing.stdout + missing.stderr).toBe(''); + }); +}); diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index aa6fca6f6..6f581b68f 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -167,6 +167,7 @@ vi.mock('../project-agent-root.js', () => ({ import { buildAdoptedSummary, buildHandlerRegistry, filterHandlersForConfig } from '../hook-handlers.js'; import { createDispatcher } from '../hook-dispatch.js'; +import { GIT_HOOK_EVENTS } from '../git-hook.js'; import type { LocalConfig } from '../types.js'; /** The scope hook-dispatch resolved for the hook's cwd, handed to every handler. */ @@ -772,10 +773,12 @@ describe('hook-handlers registry', () => { // (inline, blocking) handler must therefore stay well under that ceiling — // unified at <5s — so a slow/unreachable endpoint can never trip the host // timeout on any event. Background (detached) handlers are not awaited by the - // host, so they may keep longer budgets. + // host, so they may keep longer budgets. Git's own events (git-hook.ts) run + // under git, which has no hook timeout. it('every foreground handler timeout is under 5s', () => { const registry = buildHandlerRegistry(); - const foreground = registry.filter((r) => r.background !== true); + const gitEvents: readonly string[] = GIT_HOOK_EVENTS; + const foreground = registry.filter((r) => r.background !== true && !gitEvents.includes(r.event)); expect(foreground.length).toBeGreaterThan(0); for (const reg of foreground) { expect(reg.timeoutMs).toBeLessThan(5_000); diff --git a/src/builtin-hooks.ts b/src/builtin-hooks.ts index 98b7800e5..e31e41936 100644 --- a/src/builtin-hooks.ts +++ b/src/builtin-hooks.ts @@ -34,7 +34,7 @@ import { bundledShellFor, resetBundledRuntimeCache, resolveCodebuddyNode, resolv // resolver the wrapper writer uses, so write and lookup cannot diverge. // Other tools keep the plain `bash -lc "teamai ..."` form. -const TEAMAI_BIN_DIR = '.teamai/bin'; +export const TEAMAI_BIN_DIR = '.teamai/bin'; const WRAPPER_NAME = 'teamai'; /** diff --git a/src/git-hook.ts b/src/git-hook.ts new file mode 100644 index 000000000..9c29d0cca --- /dev/null +++ b/src/git-hook.ts @@ -0,0 +1,125 @@ +/** + * teamai's git hook: a named hook in a repository's local git config that runs + * `teamai hook-dispatch --tool git` on `post-checkout` and + * `post-merge`, so a new worktree gets the team's resources before + * `git worktree add` returns. + * + * Config hooks (`hook..command` + `hook..event`, Git >= 2.54) live + * in the common config every worktree shares, and run beside `core.hooksPath` + * and `.git/hooks` scripts, so no hook manager's files are touched. + * + * Git gives the command no event name, so each event gets its own named hook. + * Git runs the command as `sh -c ' "$@"' `: its arguments land + * on the command's last simple command. The command is therefore a function + * definition followed by its call, which receives them, and the function ends + * in `|| :` so the hook always exits 0 (a non-zero `post-checkout` becomes the + * exit status of `git worktree add`). + */ + +import { ensureTeamaiWrapper, TEAMAI_BIN_DIR } from './builtin-hooks.js'; +import { execCommand } from './utils/exec.js'; +import { log } from './utils/logger.js'; + +export const GIT_HOOK_EVENTS = ['post-checkout', 'post-merge'] as const; +export type GitHookEvent = (typeof GIT_HOOK_EVENTS)[number]; + +/** The `--tool` value of a dispatch git runs. */ +export const GIT_HOOK_TOOL = 'git'; + +/** First Git release with config-defined hooks. */ +const MIN_GIT: readonly [number, number] = [2, 54]; + +const ZERO_OID = /^0+$/; + +function hookName(event: GitHookEvent): string { + return `teamai-${event}`; +} + +/** The shell line git runs for `event`. */ +export function gitHookCommand(event: GitHookEvent): string { + return `teamai_git_hook() { PATH="$HOME/${TEAMAI_BIN_DIR}:$PATH" teamai hook-dispatch ${event} --tool ${GIT_HOOK_TOOL} "$@" >/dev/null 2>&1 || :; }; teamai_git_hook`; +} + +export type GitHookInstall = + | { installed: true; changed: boolean } + | { installed: false; reason: 'old-git' | 'not-a-repository' }; + +/** + * Write (or refresh) the hook into the local config of the repository holding + * `repoDir`. Idempotent: an up-to-date hook is left untouched. + */ +export async function installGitHook(repoDir: string): Promise { + const git = (args: string[]) => execCommand('git', args, { cwd: repoDir, timeoutMs: 10_000 }); + if (!supportsConfigHooks((await git(['--version'])).stdout)) return { installed: false, reason: 'old-git' }; + if ((await git(['rev-parse', '--git-dir'])).code !== 0) return { installed: false, reason: 'not-a-repository' }; + + // The wrapper is what the hook command finds `teamai` through when the app + // that runs git has no login PATH. + ensureTeamaiWrapper(); + + let changed = false; + for (const event of GIT_HOOK_EVENTS) { + const key = `hook.${hookName(event)}`; + const command = gitHookCommand(event); + const current = (await git(['config', '--local', '--get', `${key}.command`])).stdout.trim(); + const events = (await git(['config', '--local', '--get-all', `${key}.event`])).stdout.trim(); + if (current === command && events === event) continue; + await ok(git(['config', '--local', `${key}.command`, command]), key); + await ok(git(['config', '--local', '--replace-all', `${key}.event`, event]), key); + changed = true; + } + if (changed) log.debug(`git hook: installed teamai hooks in ${repoDir}`); + return { installed: true, changed }; +} + +async function ok(result: ReturnType, key: string): Promise { + const { code, stderr } = await result; + if (code !== 0) throw new Error(`git config ${key} failed: ${stderr.trim() || `exit ${code}`}`); +} + +function supportsConfigHooks(versionOutput: string): boolean { + const m = /(\d+)\.(\d+)/.exec(versionOutput); + if (!m) return false; + const [major, minor] = [Number(m[1]), Number(m[2])]; + return major > MIN_GIT[0] || (major === MIN_GIT[0] && minor >= MIN_GIT[1]); +} + +/** + * Whether a `post-checkout` with these arguments created a checkout (a new + * worktree): Git passes an all-zero old ref then. A branch switch passes the + * previous HEAD. + */ +export function isNewCheckout(args: readonly string[]): boolean { + const [oldRef, , branchFlag] = args; + return !!oldRef && ZERO_OID.test(oldRef) && branchFlag === '1'; +} + +/** + * Variables through which Git hands a hook the repository it runs for + * (`git rev-parse --local-env-vars`, minus GIT_CONFIG_COUNT and its + * GIT_CONFIG_KEY/VALUE pairs, which a member sets in their own environment and + * Git never adds for a hook). Every git child teamai starts would inherit them + * and act on the business repo instead of the team clone it names. + */ +const REPOSITORY_ENV = [ + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_CONFIG', + 'GIT_CONFIG_PARAMETERS', + 'GIT_OBJECT_DIRECTORY', + 'GIT_DIR', + 'GIT_WORK_TREE', + 'GIT_IMPLICIT_WORK_TREE', + 'GIT_GRAFT_FILE', + 'GIT_INDEX_FILE', + 'GIT_NO_REPLACE_OBJECTS', + 'GIT_REPLACE_REF_BASE', + 'GIT_PREFIX', + 'GIT_INTERNAL_SUPER_PREFIX', + 'GIT_SHALLOW_FILE', + 'GIT_COMMON_DIR', +] as const; + +/** Drop the repository Git exported for the hook, before teamai runs any git. */ +export function clearGitHookRepositoryEnv(env: NodeJS.ProcessEnv = process.env): void { + for (const name of REPOSITORY_ENV) delete env[name]; +} diff --git a/src/hook-dispatch-cli.ts b/src/hook-dispatch-cli.ts index 26fd3dc0a..776f10f73 100644 --- a/src/hook-dispatch-cli.ts +++ b/src/hook-dispatch-cli.ts @@ -26,7 +26,8 @@ import { createDispatcher, type Dispatcher } from './hook-dispatch.js'; import { buildHandlerRegistry, filterHandlersForConfig } from './hook-handlers.js'; import { resolveHookCwd } from './utils/hook-cwd.js'; import { windowsPowerShell } from './utils/powershell.js'; -import { log, setStderrOnly } from './utils/logger.js'; +import { log, setSilent, setStderrOnly } from './utils/logger.js'; +import { clearGitHookRepositoryEnv, GIT_HOOK_TOOL } from './git-hook.js'; import { deriveDispatchSessionId } from './utils/session-id.js'; import { claudeHookRunsInAnotherHost } from './claude-hook-host.js'; @@ -414,7 +415,7 @@ export async function hookDispatchCli( event: string, tool: string, matcher: string, - options: { bgOnly?: boolean; stdinFile?: string } = {}, + options: { bgOnly?: boolean; stdinFile?: string; hookArgs?: string[] } = {}, ): Promise { const { bgOnly = false, stdinFile } = options; setStderrOnly(true); @@ -422,8 +423,19 @@ export async function hookDispatchCli( log.debug('hook-dispatch: skipping claude hooks because Cursor or Copilot CLI has its own teamai hooks'); return; } + // A git hook (see git-hook.ts) prints nothing, and runs with the business + // repo exported in GIT_DIR and friends, which every git child would inherit. + const fromGit = tool === GIT_HOOK_TOOL; + if (fromGit) { + setSilent(true); + clearGitHookRepositoryEnv(); + } try { - const raw = stdinFile ? readStdinFile(stdinFile) : await readStdin(); + // Git sends no payload: its arguments and the checkout it runs in are the + // payload. The detached child gets them back through the STDIN file. + const raw = stdinFile ? readStdinFile(stdinFile) + : fromGit ? JSON.stringify({ cwd: process.cwd(), git_args: options.hookArgs ?? [] }) + : await readStdin(); const stdin = parseStdin(raw, event); // Config gates: a directory without teamai runs no team handlers (#748), and diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 4358ed31c..ebd3fde36 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -140,6 +140,43 @@ const pullHandler: HookHandler = { }, }; +/** + * `post-checkout` from the git hook: a new checkout (a linked worktree) of a + * project-scope repository gets its tool roots and the team's resources. A + * branch switch, user scope (whose resources live in HOME), and checkouts + * teamai itself creates (its knowledge and reports worktrees, under the scope's + * data home or team clone) do nothing. + */ +const newWorktreeHandler: HookHandler = { + name: 'new-worktree', + async execute(stdin, _tool, config) { + const { isNewCheckout } = await import('./git-hook.js'); + const args = Array.isArray(stdin.git_args) ? stdin.git_args.map(String) : []; + if (!config || config.scope !== 'project' || !isNewCheckout(args)) return null; + const cwd = resolveHookCwd(stdin) ?? process.cwd(); + const { getDataHome } = await import('./types.js'); + if (await isWithin(cwd, [getDataHome(config), config.repo.localPath])) return null; + + const { createProjectToolRoots } = await import('./project-agent-root.js'); + await createProjectToolRoots({ cwd }); + const { pull } = await import('./pull.js'); + await pull({ silent: true }); + return null; + }, +}; + +/** Whether `dir` is one of `parents` or inside one (real paths). */ +async function isWithin(dir: string, parents: string[]): Promise { + const { realpath } = await import('node:fs/promises'); + const real = (p: string) => realpath(p).catch(() => path.resolve(p)); + const target = await real(dir); + for (const parent of parents) { + const rel = path.relative(await real(parent), target); + if (!rel.startsWith('..') && !path.isAbsolute(rel)) return true; + } + return false; +} + const updateHandler: HookHandler = { name: 'update', async execute(_stdin, _tool) { @@ -917,6 +954,12 @@ export function buildHandlerRegistry(): HandlerRegistration[] { { event: 'prompt-submit', matcher: '*', handler: trackSlashHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'prompt-submit', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'prompt-submit', matcher: '*', handler: localAgentHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, + + // ─── Git (`--tool git`, see git-hook.ts) ────────── + // Inline: the delivery has to land before `git worktree add` returns. Git + // has no hook timeout, so the budget is the detached pull's. `post-merge` + // is installed but has no handler yet. + { event: 'post-checkout', matcher: '*', handler: newWorktreeHandler, timeoutMs: PULL_TIMEOUT_MS, requiresConfig: true }, ]; } diff --git a/src/hooks.ts b/src/hooks.ts index 78e924a7c..07f640fa4 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1873,6 +1873,7 @@ export async function reconcileTeamHooksForConfig( } return resolved.ok ? { ok: true, defs: teamDefs } : { ok: false, builtins: builtinsOnly ?? 'with-overrides' }; } + if (!opts.removeAll) await installProjectGitHook(localConfig); await reconcileHooksToAllTools(hookToolPaths, baseDir, teamDefs, manifestPath, { removeAll: opts.removeAll, builtinOverride: builtin, @@ -1917,6 +1918,24 @@ export async function reconcileTeamHooksForConfig( return { ok: true, defs: teamDefs }; } +/** + * Project scope: install teamai's git hook (git-hook.ts) in the repository, so + * a new worktree gets the team's resources before `git worktree add` returns. + * User scope installs none: its resources live in HOME, which a new worktree + * does not change. A failure is reported and does not stop the caller. + */ +async function installProjectGitHook(localConfig: LocalConfig): Promise { + if (localConfig.scope !== 'project' || !localConfig.projectRoot) return; + const { installGitHook } = await import('./git-hook.js'); + try { + const result = await installGitHook(localConfig.projectRoot); + if (!result.installed) log.debug(`git hook: not installed in ${localConfig.projectRoot} (${result.reason})`); + } catch (e) { + log.warn(`Could not install the teamai git hook in ${localConfig.projectRoot}: ${(e as Error).message}. ` + + 'New worktrees get the team\'s resources at their first session instead; the next `teamai pull` retries.'); + } +} + /** * The line `init` and bootstrap print when the team hooks did not resolve. The * reason, naming the file, was already reported by the resolution. diff --git a/src/index.ts b/src/index.ts index fbc8f6d1c..41b9468b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -997,15 +997,16 @@ program }); program - .command('hook-dispatch ', { hidden: true }) + .command('hook-dispatch [hookArgs...]', { hidden: true }) .description('Unified hook dispatcher — handles all teamai hooks for a given event in one process') .option('--stdin', 'Read hook data from STDIN (accepted for forward compat, always reads STDIN)') .option('--tool ', 'Tool identifier (e.g. codebuddy, workbuddy, claude)') .option('--matcher ', 'Hook matcher for PostToolUse (e.g. Skill, Bash)') .option('--bg-only', 'Internal: run only fire-and-forget background handlers (used by the detached child)') .option('--stdin-file ', 'Internal: read the hook payload from this file instead of STDIN') - .action(async (event: string, cmdOpts: { stdin?: boolean; tool?: string; matcher?: string; bgOnly?: boolean; stdinFile?: string }) => { + .action(async (event: string, hookArgs: string[], cmdOpts: { stdin?: boolean; tool?: string; matcher?: string; bgOnly?: boolean; stdinFile?: string }) => { const bgOnly = cmdOpts.bgOnly ?? false; + const tool = cmdOpts.tool ?? 'claude'; // Hard wall-clock safety net for the FOREGROUND (parent) hook process, which // blocks the host IDE's hook. The host aborts a hook at ~10s regardless of @@ -1018,15 +1019,17 @@ program // The detached `--bg-only` child is unref'd and not awaited by the host, so // it is exempt and keeps its full budget to finish real syncs/downloads. const HOOK_HARD_EXIT_MS = 7_000; + // Git (`--tool git`) has no hook timeout to stay under, and a cut here + // would stop the inline delivery mid-write; its handler bounds itself. let hardExit: NodeJS.Timeout | undefined; - if (!bgOnly) { + if (!bgOnly && tool !== 'git') { hardExit = setTimeout(() => process.exit(0), HOOK_HARD_EXIT_MS); hardExit.unref(); } const { hookDispatchCli } = await import('./hook-dispatch-cli.js'); try { - await hookDispatchCli(event, cmdOpts.tool ?? 'claude', cmdOpts.matcher ?? '*', cmdOpts); + await hookDispatchCli(event, tool, cmdOpts.matcher ?? '*', { ...cmdOpts, hookArgs }); } finally { if (hardExit) clearTimeout(hardExit); // Hook subprocesses must exit promptly: a hung/unreachable backend fetch can From 8fe7d5e97cc44a464fb0a20e421698f36940622b Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 11:05:05 +0200 Subject: [PATCH 04/14] feat(hooks): deliver a new worktree from the cached team clone, refresh the rest after The post-checkout pass now runs pull in an internal inline mode: it skips the team repo fetch when the clone was fetched within the source TTL (24 h, new last-fetch.json stamp beside the clone), reads subscribed sources from their cached clones, waits for another pull's partition lock instead of skipping, and leaves learnings, reports, usage reporting and post-pull scripts to a detached full pull started after it. --- docs/usage-guide.md | 5 +- docs/usage-guide.zh-CN.md | 2 + skill-data/core/SKILL.md | 3 +- .../e2e/git-hook-new-worktree.test.ts | 104 +++++++++++++++++- src/hook-handlers.ts | 9 +- src/pull.ts | 52 +++++++-- src/source.ts | 8 +- src/types.ts | 7 ++ 8 files changed, 176 insertions(+), 14 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 42d9d3d90..7ece44648 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -177,7 +177,10 @@ manager and any `.git/hooks` script. When `git worktree add`, or an app that cre worktrees, makes a new checkout, the hook creates the project roots of `enabledAgents` (when that is empty, the roots the main checkout has) and pulls into the worktree before the command returns, so the first session there already has the -team's skills, rules and MCP servers. A branch switch does nothing, and `post-merge` +team's skills, rules and MCP servers. That pull reads the team clone as it is when it +was fetched in the last 24 hours (and fetches it first otherwise), and subscribed +sources from their cached clones; a full `teamai pull --silent` then runs in the +background to fetch the team repo, sources, learnings and reports. A branch switch does nothing, and `post-merge` does nothing yet. The hook prints nothing and always exits 0, so a failed pull never fails the git command. It follows the scope rules below: no project config, or one that cannot be read, means no sync. The command is one `sh` line that runs diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 3e7c50039..16c3517e9 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -168,6 +168,8 @@ git 配置中安装一个 git hook,所有 worktree 共用:`hook.teamai-post- `core.hooksPath` hook 管理器和 `.git/hooks` 脚本之外一并运行它。当 `git worktree add`,或会创建 worktree 的应用,新建一个检出时,该 hook 会创建 `enabledAgents` 的项目根目录(为空时,取主检出已有的根目录), 并在命令返回前向该 worktree 执行 pull,因此其中的第一次会话就已具备团队的 skill、rule 与 MCP 服务器。 +团队仓库克隆若在 24 小时内 fetch 过,这次 pull 直接读取它(否则先 fetch),订阅的 source 读取其缓存克隆; +随后在后台运行一次完整的 `teamai pull --silent`,fetch 团队仓库、source、learnings 与 reports。 切换分支不会触发任何操作,`post-merge` 目前也不做任何事。该 hook 不输出任何内容且始终以 0 退出, 因此 pull 失败也不会让 git 命令失败。它遵循下文的 scope 规则:没有项目配置,或项目配置无法读取, 都不会同步。其命令是一行 `sh`,带着 Git 传入的参数运行 `teamai hook-dispatch --tool git`, diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 6553acfa7..365c4cbd2 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -132,7 +132,8 @@ local git config (`hook.teamai-post-checkout`, `hook.teamai-post-merge`; Git 2.54+), beside any `core.hooksPath` manager or `.git/hooks` script. When a worktree is created (`git worktree add`, or an app), it creates the project roots of `enabledAgents` (else the ones the main checkout has) and pulls into it before -the command returns. A branch switch does nothing. It prints nothing and always +the command returns, from the team clone as last fetched when that was within +24 h; a full pull then runs in the background. A branch switch does nothing. It prints nothing and always exits 0, so a worktree still missing team resources needs a `teamai pull` there; `git config --local --get-regexp '^hook\.teamai-'` shows whether it is installed. diff --git a/src/__tests__/e2e/git-hook-new-worktree.test.ts b/src/__tests__/e2e/git-hook-new-worktree.test.ts index 192499c88..ecea8afa1 100644 --- a/src/__tests__/e2e/git-hook-new-worktree.test.ts +++ b/src/__tests__/e2e/git-hook-new-worktree.test.ts @@ -132,7 +132,8 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc }, 60_000); afterAll(() => { - if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + // Detached pulls of the last worktrees may still be writing. + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }); }); it('init installs one named hook per git event in the repository config', () => { @@ -284,6 +285,107 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc expect(delivered(dir)).toEqual(ALL); }); + describe('inline pass reads the team clone, the detached pull fetches the rest', () => { + const stampOf = (root: string) => path.join(partitionOf(root), 'last-fetch.json'); + /** Commit a new skill to the team remote; returns its name. */ + const pushSkill = (name: string): void => { + const work = path.join(sandbox, `push-${name}`); + gitOk(['clone', '-q', remote, work], sandbox); + fs.mkdirSync(path.join(work, 'skills', name), { recursive: true }); + fs.writeFileSync(path.join(work, 'skills', name, 'SKILL.md'), `---\nname: ${name}\ndescription: ${name}\n---\n`); + gitOk(['add', '-A'], work); + gitOk(['commit', '-q', '-m', name], work); + gitOk(['push', '-q', 'origin', 'HEAD:main'], work); + }; + const hasSkill = (dir: string, name: string) => fs.existsSync(path.join(dir, '.claude', 'skills', name, 'SKILL.md')); + const waitFor = async (check: () => boolean, ms = 30_000) => { + const end = Date.now() + ms; + while (Date.now() < end) { + if (check()) return true; + await new Promise((r) => setTimeout(r, 200)); + } + return check(); + }; + /** + * Wait for the detached pulls of the project to finish: no partition lock + * for a full second (one may not have taken it yet). + */ + const settle = async (root: string) => { + const lock = path.join(partitionOf(root), '.sync-lock'); + let freeSince = Date.now(); + await waitFor(() => { + if (fs.existsSync(lock)) freeSince = Date.now(); + return Date.now() - freeSince >= 1000; + }); + }; + + it('a clone fetched within the TTL: no fetch while the hook runs; the detached pull fetches afterwards', async () => { + const repo = project('fresh-project', ['--agent', 'claude']); + await settle(repo); + expect(fs.existsSync(stampOf(repo))).toBe(true); + pushSkill('late-skill'); + const trace = path.join(sandbox, 'fresh-trace.log'); + + const wt = worktreeAdd(repo, 'wt-fresh', { GIT_TRACE: trace }); + + expect(wt.code, wt.output).toBe(0); + expect(wt.output).toBe(''); + expect(delivered(wt.dir)).toEqual(ALL); + expect(hasSkill(wt.dir, 'late-skill')).toBe(false); + const traced = fs.readFileSync(trace, 'utf8'); + expect(traced).toMatch(/hook-dispatch post-checkout/); + expect(traced).not.toMatch(/\b(fetch|upload-pack|pull)\b/); + + expect(await waitFor(() => hasSkill(wt.dir, 'late-skill'))).toBe(true); + await settle(repo); + }); + + it('a clone fetched more than 24 h ago: the hook fetches the team repo before delivering', async () => { + const repo = project('stale-project', ['--agent', 'claude']); + await settle(repo); + fs.writeFileSync(stampOf(repo), JSON.stringify({ lastFetch: new Date(Date.now() - 25 * 3600_000).toISOString() })); + pushSkill('stale-skill'); + + const wt = worktreeAdd(repo, 'wt-stale'); + + expect(wt.code, wt.output).toBe(0); + expect(hasSkill(wt.dir, 'stale-skill')).toBe(true); + await settle(repo); + }); + + it('delivers what a full pull delivers at the same team revision', async () => { + const repo = project('equal-project', ['--agent', 'claude']); + await settle(repo); + const tree = (dir: string): string[] => { + const out: string[] = []; + const walk = (rel: string) => { + for (const e of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) { + const r = path.join(rel, e.name); + if (e.isDirectory()) walk(r); + else out.push(`${r}:${fs.readFileSync(path.join(dir, r), 'utf8')}`); + } + }; + for (const top of ['.claude', '.mcp.json']) { + if (!fs.existsSync(path.join(dir, top))) continue; + if (fs.statSync(path.join(dir, top)).isDirectory()) walk(top); + else out.push(`${top}:${fs.readFileSync(path.join(dir, top), 'utf8')}`); + } + return out.sort(); + }; + const hooked = worktreeAdd(repo, 'wt-equal-hook'); + const snapshot = tree(hooked.dir); + await settle(repo); + const manual = path.join(sandbox, 'wt-equal-pull'); + gitOk(['-c', 'hook.teamai-post-checkout.enabled=false', 'worktree', 'add', '-q', manual], repo); + fs.mkdirSync(path.join(manual, '.claude')); // the root the hook creates; pull skips a tool without one + const pulled = teamai(['pull', '--silent'], manual); + expect(pulled.code, pulled.output).toBe(0); + + expect(snapshot.length).toBeGreaterThan(0); + expect(snapshot).toEqual(tree(manual)); + }); + }); + it('with no enabledAgents, creates the tool roots the main checkout has, and only those', () => { const codexProject = project('codex-project', []); fs.mkdirSync(path.join(codexProject, '.codex')); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index ebd3fde36..24604860d 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -160,7 +160,14 @@ const newWorktreeHandler: HookHandler = { const { createProjectToolRoots } = await import('./project-agent-root.js'); await createProjectToolRoots({ cwd }); const { pull } = await import('./pull.js'); - await pull({ silent: true }); + await pull({ silent: true, inline: true }); + // Learnings, reports, sources and the team repo itself refresh after + // `git worktree add` returns, in a pull this process does not wait for. + const { resolveCliEntry } = await import('./builtin-hooks.js'); + const { spawn } = await import('node:child_process'); + spawn(process.execPath, [resolveCliEntry() ?? '', 'pull', '--silent'], { + cwd, detached: true, stdio: 'ignore', windowsHide: true, + }).on('error', (e) => log.debug(`new-worktree: detached pull failed to start: ${e.message}`)).unref(); return null; }, }; diff --git a/src/pull.ts b/src/pull.ts index f3021a361..7957f7b76 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -12,7 +12,7 @@ import { publishQueuedLearnings } from './utils/learnings-publish.js'; import { pendingLearningsDir } from './utils/pending-learnings.js'; import { indexableLearningsRoots } from './utils/learnings-roots.js'; import { log, spinner } from './utils/logger.js'; -import { pathExists, remove, listFiles, listDirs, listFilesRecursive, readFileSafe, dirContentEqual, hasVcsMetadataRecursive } from './utils/fs.js'; +import { pathExists, readJson, writeJson, remove, listFiles, listDirs, listFilesRecursive, readFileSafe, dirContentEqual, hasVcsMetadataRecursive } from './utils/fs.js'; import { reconcilePlacementRecords } from './utils/pending-push.js'; import { injectClaudeMdSection, removeClaudeMdSection } from './utils/claudemd.js'; import { getHandler, RulesHandler, DocsHandler, EnvHandler, AgentsHandler } from './resources/index.js'; @@ -47,6 +47,7 @@ import { SYNC_LOCK_FILENAME, usesBranchWorktree, managedMcpWorkspaceId, + SOURCE_PULL_TTL_MS, } from './types.js'; import type { CultureFrontmatter } from './types.js'; import { deliversEveryNamespace } from './resource-namespaces.js'; @@ -84,8 +85,27 @@ const FILE_NOT_FOUND_ERROR_CODE = 'ENOENT'; * tree on disk: the caller must then NOT take that same fast path *this* run, * because the parent rev alone cannot see the change (issue #525). */ +/** + * When the team clone was last fetched, beside the clone (as a source cache's + * last-pull.json). A fresh clone has no FETCH_HEAD, and state.lastPull moves + * only on a full sync, so neither can tell. + */ +function teamFetchStamp(localConfig: LocalConfig): string { + return path.join(path.dirname(localConfig.repo.localPath), 'last-fetch.json'); +} + +/** How long an inline pull waits for another pull's partition lock. */ +const INLINE_LOCK_WAIT_MS = 60_000; + +async function teamFetchedWithinTtl(localConfig: LocalConfig): Promise { + const stamp = await readJson<{ lastFetch: string }>(teamFetchStamp(localConfig)); + const elapsed = stamp ? Date.now() - new Date(stamp.lastFetch).getTime() : NaN; + return Number.isFinite(elapsed) && elapsed >= 0 && elapsed <= SOURCE_PULL_TTL_MS; +} + async function refreshTeamRepo( localConfig: LocalConfig, + options: Pick = {}, ): Promise<{ label: string; version: string | null; submodulesFailed: boolean; submodulesChanged: boolean }> { if (localConfig.repo.kind === 'http') { const { resolveApiKey } = await import('./api-key.js'); @@ -129,7 +149,14 @@ async function refreshTeamRepo( // the reconcile/source/report stages — so there is no unlocked window in which // another writer could reset/checkout the tree. We must NOT lock here: the lock // is non-reentrant, so re-acquiring it in the same process would fail. + // The new-worktree hook reads a recently fetched clone as it is. + if (options.inline && await teamFetchedWithinTtl(localConfig)) { + const version = await getHeadRev(localConfig.repo.localPath).catch(() => null); + return { label: 'fetched within the TTL, not refetched', version, submodulesFailed: false, submodulesChanged: false }; + } const result = await pullRepo(localConfig.repo.localPath); + await writeJson(teamFetchStamp(localConfig), { lastFetch: new Date().toISOString() }) + .catch((e) => log.debug(`Could not record the team repo fetch: ${(e as Error).message}`)); let version: string | null = null; try { @@ -913,7 +940,7 @@ async function pullForScope( // unchanged-rev fast path for THIS run — the parent rev cannot see it (#525). let submodulesChanged = false; try { - const refresh = await refreshTeamRepo(localConfig); + const refresh = await refreshTeamRepo(localConfig, options); currentRev = refresh.version; submodulesFailed = refresh.submodulesFailed; submodulesChanged = refresh.submodulesChanged; @@ -955,7 +982,8 @@ async function pullForScope( // pull will retry, and that has to hold in every mode. pull() holds the // partition sync lock across this scope and the lock is not reentrant, so // publishing must not try to take it again. Never let it block the pull. - try { + // Inline (new-worktree hook) it is left to the detached pull after it. + if (!options.inline) try { const queue = await publishQueuedLearnings(localConfig, localConfig.username, { holdsSyncLock: true, dryRun: options.dryRun }); if (options.dryRun) { if (queue.remaining > 0) log.info(`[${scopeLabel}] [dry-run] Would publish ${queue.remaining} queued learning(s)`); @@ -1048,7 +1076,8 @@ async function pullForScope( // and it never publishes, so running it on the fast path cannot flush pending // learnings outside the caller's partition sync lock. const syncLearningsAndRebuildIndex = async (): Promise => { - if (options.dryRun) return; + // Inline (new-worktree hook): the detached pull after it does this. + if (options.dryRun || options.inline) return; try { // Bring the learnings branch up to date before reading it, or a member // only ever sees their own contributions. Read-only: a cold start @@ -2200,7 +2229,16 @@ export async function pull( // what the real run would have found: a live holder reports this scope as // contended and skips it, exactly as a real pull does. Nothing is recorded // for release, because nothing was taken. - if (await acquireLock(lock, { dryRun: options.dryRun })) { + // Inline (new-worktree hook), a skipped scope is a worktree without the + // team's resources, and the holder is often the detached pull of the + // worktree created just before: wait for it, within the hook's budget. + const waitUntil = options.inline ? Date.now() + INLINE_LOCK_WAIT_MS : 0; + let acquired = await acquireLock(lock, { dryRun: options.dryRun }); + while (!acquired && Date.now() < waitUntil) { + await new Promise((resolve) => setTimeout(resolve, 200)); + acquired = await acquireLock(lock, { dryRun: options.dryRun }); + } + if (acquired) { if (!options.dryRun) heldLocks.set(config, lock); return true; } @@ -2349,7 +2387,7 @@ export async function pull( // truncates only its own file. Dashboard sessions live in one shared file // and are filtered instead: each target gets the sessions its scope // recorded (#785). - if (!options.dryRun && !pendingUsageReport) { + if (!options.dryRun && !options.inline && !pendingUsageReport) { pendingUsageReport = (async () => { try { const { reportUsageToTeam } = await import('./team-push.js'); @@ -2474,7 +2512,7 @@ export async function pull( // usage report (above) may still hold them; its writes go to the reports // worktree, not this clone's tree. Launch shape: post-pull.ts. Nothing // here can fail the pull. - if (!options.dryRun && postPullRepo) { + if (!options.dryRun && !options.inline && postPullRepo) { await runDeclaredPostPull(postPullRepo, { interactive: options.interactive === true }); } } diff --git a/src/source.ts b/src/source.ts index 75ae912e5..294085c51 100644 --- a/src/source.ts +++ b/src/source.ts @@ -198,8 +198,10 @@ async function recordSourcePull(source: SourceConfig): Promise { /** * Clone or pull a source repo. Returns the repo path, or null on failure. */ -async function ensureSourceRepo(source: SourceConfig, force: boolean, dryRun = false): Promise { +async function ensureSourceRepo(source: SourceConfig, force: boolean, dryRun = false, offline = false): Promise { const repoDir = getSourceRepoDir(source); + // The new-worktree hook reads the cached clone, as a dry run does (#929). + if (offline) return await pathExists(repoDir) ? repoDir : null; if (dryRun) { if (!await pathExists(repoDir)) { log.info(`[dry-run] [source:${source.name}] Would clone the repository; no cached skills are available to preview.`); @@ -555,7 +557,7 @@ async function sourceBrowseLocked(name: string, options: GlobalOptions, localCon } // Ensure source repo is cloned - const repoDir = await ensureSourceRepo(source, !!options.force, !!options.dryRun); + const repoDir = await ensureSourceRepo(source, !!options.force, !!options.dryRun, !!options.inline); if (!repoDir) { if (!options.dryRun) log.error(`Could not access source "${name}".`); return; @@ -639,7 +641,7 @@ async function pullSingleSource( options: GlobalOptions, ): Promise { // Ensure source repo is cloned/updated - const repoDir = await ensureSourceRepo(source, !!options.force, !!options.dryRun); + const repoDir = await ensureSourceRepo(source, !!options.force, !!options.dryRun, !!options.inline); if (!repoDir) return; // Load source's teamai.yaml diff --git a/src/types.ts b/src/types.ts index deab01b95..bb05aac92 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1066,6 +1066,13 @@ export interface GlobalOptions { * the confirmation prompt (`remove`). */ force?: boolean; + /** + * Internal (the new-worktree git hook, `pull` only): deliver what the next + * session reads, without network when the team clone was fetched within + * SOURCE_PULL_TTL_MS; sources come from their cached clones. Learnings, + * reports, usage reporting and post-pull scripts are left to a full pull. + */ + inline?: boolean; /** Push a specific skill by path. */ skill?: string; /** Target role namespace (overrides detected namespace). */ From 055552eccdc6df9d182c7f83415870b8d3a76501 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 11:18:26 +0200 Subject: [PATCH 05/14] feat(hooks): deliver the team's change after git pull The post-merge git hook now runs an inline pull. With a separate team repo it fetches the team repo whatever its fetch stamp says, aborting the fetch after 5 s (internal fetchTimeoutMs pull option, AbortSignal into simple-git), and delivers when the revision moved; a detached full pull then refreshes the team repo past the cap, sources, learnings and reports. In single-repo mode it delivers the working tree git pull updated, with no network and no detached pull. --- docs/usage-guide.md | 7 +- docs/usage-guide.zh-CN.md | 4 +- skill-data/core/SKILL.md | 4 +- .../e2e/git-hook-new-worktree.test.ts | 169 ++++++++++++++---- src/hook-handlers.ts | 54 +++++- src/pull.ts | 13 +- src/types.ts | 6 + src/utils/git.ts | 10 +- 8 files changed, 212 insertions(+), 55 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 7ece44648..5d6e70be5 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -180,8 +180,11 @@ the worktree before the command returns, so the first session there already has team's skills, rules and MCP servers. That pull reads the team clone as it is when it was fetched in the last 24 hours (and fetches it first otherwise), and subscribed sources from their cached clones; a full `teamai pull --silent` then runs in the -background to fetch the team repo, sources, learnings and reports. A branch switch does nothing, and `post-merge` -does nothing yet. The hook prints nothing and always exits 0, so a failed pull never +background to fetch the team repo, sources, learnings and reports. A branch switch does nothing. +After `git pull` (`post-merge`), the hook fetches the team repo, waiting at most 5 seconds, +and delivers its changes before `git pull` returns; past 5 seconds, and for sources, +learnings and reports, the same background pull takes over. In single-repo mode it +delivers the knowledge `git pull` just brought, with no network. The hook prints nothing and always exits 0, so a failed pull never fails the git command. It follows the scope rules below: no project config, or one that cannot be read, means no sync. The command is one `sh` line that runs `teamai hook-dispatch --tool git` with Git's arguments, finding `teamai` diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 16c3517e9..fc87c0217 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -170,7 +170,9 @@ worktree 的应用,新建一个检出时,该 hook 会创建 `enabledAgents` 并在命令返回前向该 worktree 执行 pull,因此其中的第一次会话就已具备团队的 skill、rule 与 MCP 服务器。 团队仓库克隆若在 24 小时内 fetch 过,这次 pull 直接读取它(否则先 fetch),订阅的 source 读取其缓存克隆; 随后在后台运行一次完整的 `teamai pull --silent`,fetch 团队仓库、source、learnings 与 reports。 -切换分支不会触发任何操作,`post-merge` 目前也不做任何事。该 hook 不输出任何内容且始终以 0 退出, +切换分支不会触发任何操作。`git pull` 之后(`post-merge`),该 hook 会 fetch 团队仓库(最多等待 5 秒), +并在 `git pull` 返回前交付其变更;超过 5 秒时,以及 source、learnings 与 reports,交给同样的后台 pull。 +单仓库模式下,它交付 `git pull` 刚带来的知识,不访问网络。该 hook 不输出任何内容且始终以 0 退出, 因此 pull 失败也不会让 git 命令失败。它遵循下文的 scope 规则:没有项目配置,或项目配置无法读取, 都不会同步。其命令是一行 `sh`,带着 Git 传入的参数运行 `teamai hook-dispatch --tool git`, 与 Agent hook 一样通过 `~/.teamai/bin` 找到 `teamai`。 diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 365c4cbd2..087b90729 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -133,7 +133,9 @@ local git config (`hook.teamai-post-checkout`, `hook.teamai-post-merge`; Git worktree is created (`git worktree add`, or an app), it creates the project roots of `enabledAgents` (else the ones the main checkout has) and pulls into it before the command returns, from the team clone as last fetched when that was within -24 h; a full pull then runs in the background. A branch switch does nothing. It prints nothing and always +24 h; a full pull then runs in the background. A branch switch does nothing. +After `git pull` it fetches the team repo (5 s cap, then the background pull) and +delivers; in single-repo mode it delivers what `git pull` brought, offline. It prints nothing and always exits 0, so a worktree still missing team resources needs a `teamai pull` there; `git config --local --get-regexp '^hook\.teamai-'` shows whether it is installed. diff --git a/src/__tests__/e2e/git-hook-new-worktree.test.ts b/src/__tests__/e2e/git-hook-new-worktree.test.ts index ecea8afa1..d486f77a6 100644 --- a/src/__tests__/e2e/git-hook-new-worktree.test.ts +++ b/src/__tests__/e2e/git-hook-new-worktree.test.ts @@ -3,7 +3,7 @@ * returns. `teamai init` (project scope) installs a named hook in the * repository's git config; real git runs it on `post-checkout`, and it calls * the real CLI's dispatcher, which creates the tool roots and pulls into the - * new worktree. + * new worktree. `git pull` (`post-merge`) brings the team's change the same way. * * The team remote is a local bare repo reached through a synthetic HTTPS URL * (`url..insteadOf` in the sandbox HOME), as in init-project-all.test.ts. @@ -11,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { execFileSync, spawnSync } from 'node:child_process'; import fs from 'node:fs'; +import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -285,40 +286,40 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc expect(delivered(dir)).toEqual(ALL); }); - describe('inline pass reads the team clone, the detached pull fetches the rest', () => { - const stampOf = (root: string) => path.join(partitionOf(root), 'last-fetch.json'); - /** Commit a new skill to the team remote; returns its name. */ - const pushSkill = (name: string): void => { - const work = path.join(sandbox, `push-${name}`); - gitOk(['clone', '-q', remote, work], sandbox); - fs.mkdirSync(path.join(work, 'skills', name), { recursive: true }); - fs.writeFileSync(path.join(work, 'skills', name, 'SKILL.md'), `---\nname: ${name}\ndescription: ${name}\n---\n`); - gitOk(['add', '-A'], work); - gitOk(['commit', '-q', '-m', name], work); - gitOk(['push', '-q', 'origin', 'HEAD:main'], work); - }; - const hasSkill = (dir: string, name: string) => fs.existsSync(path.join(dir, '.claude', 'skills', name, 'SKILL.md')); - const waitFor = async (check: () => boolean, ms = 30_000) => { - const end = Date.now() + ms; - while (Date.now() < end) { - if (check()) return true; - await new Promise((r) => setTimeout(r, 200)); - } - return check(); - }; - /** - * Wait for the detached pulls of the project to finish: no partition lock - * for a full second (one may not have taken it yet). - */ - const settle = async (root: string) => { - const lock = path.join(partitionOf(root), '.sync-lock'); - let freeSince = Date.now(); - await waitFor(() => { - if (fs.existsSync(lock)) freeSince = Date.now(); - return Date.now() - freeSince >= 1000; - }); - }; + const stampOf = (root: string) => path.join(partitionOf(root), 'last-fetch.json'); + /** Commit a new skill to the team remote; returns its name. */ + const pushSkill = (name: string): void => { + const work = path.join(sandbox, `push-${name}`); + gitOk(['clone', '-q', remote, work], sandbox); + fs.mkdirSync(path.join(work, 'skills', name), { recursive: true }); + fs.writeFileSync(path.join(work, 'skills', name, 'SKILL.md'), `---\nname: ${name}\ndescription: ${name}\n---\n`); + gitOk(['add', '-A'], work); + gitOk(['commit', '-q', '-m', name], work); + gitOk(['push', '-q', 'origin', 'HEAD:main'], work); + }; + const hasSkill = (dir: string, name: string) => fs.existsSync(path.join(dir, '.claude', 'skills', name, 'SKILL.md')); + const waitFor = async (check: () => boolean, ms = 30_000) => { + const end = Date.now() + ms; + while (Date.now() < end) { + if (check()) return true; + await new Promise((r) => setTimeout(r, 200)); + } + return check(); + }; + /** + * Wait for the detached pulls of the project to finish: no partition lock + * for a full second (one may not have taken it yet). + */ + const settle = async (root: string) => { + const lock = path.join(partitionOf(root), '.sync-lock'); + let freeSince = Date.now(); + await waitFor(() => { + if (fs.existsSync(lock)) freeSince = Date.now(); + return Date.now() - freeSince >= 1000; + }); + }; + describe('inline pass reads the team clone, the detached pull fetches the rest', () => { it('a clone fetched within the TTL: no fetch while the hook runs; the detached pull fetches afterwards', async () => { const repo = project('fresh-project', ['--agent', 'claude']); await settle(repo); @@ -386,6 +387,106 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc }); }); + describe('git pull (post-merge) brings the team\'s change before the next session', () => { + /** Give `root` an origin a teammate pushes to; returns a function that pushes one commit. */ + const withOrigin = (root: string): (() => void) => { + const bare = `${root}.git`; + gitOk(['clone', '-q', '--bare', root, bare], sandbox); + gitOk(['remote', 'add', 'origin', bare], root); + gitOk(['fetch', '-q', 'origin'], root); + gitOk(['branch', '-q', '-u', 'origin/main'], root); + const mate = `${root}-mate`; + gitOk(['clone', '-q', bare, mate], sandbox); + let n = 0; + return () => { + fs.writeFileSync(path.join(mate, `change-${++n}.txt`), `${n}\n`); + gitOk(['add', '-A'], mate); + gitOk(['commit', '-q', '-m', `change ${n}`], mate); + gitOk(['push', '-q', 'origin', 'HEAD:main'], mate); + }; + }; + + it('separate team repo: a skill the team published is delivered before git pull returns', async () => { + const repo = project('merge-project', ['--agent', 'claude']); + const businessChange = withOrigin(repo); + await settle(repo); + pushSkill('merged-skill'); + businessChange(); + + const r = git(['pull', '-q'], repo); + + expect(r.code, r.output).toBe(0); + expect(r.output).toBe(''); + expect(fs.existsSync(path.join(repo, 'change-1.txt'))).toBe(true); + expect(hasSkill(repo, 'merged-skill')).toBe(true); + await settle(repo); + }); + + it('separate team repo unreachable: git pull returns within the cap and exits as git would', async () => { + const repo = project('hang-project', ['--agent', 'claude']); + const businessChange = withOrigin(repo); + await settle(repo); + businessChange(); + // A team remote that accepts the connection and never answers. + const sockets: net.Socket[] = []; + const server = net.createServer((socket) => { sockets.push(socket); }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as net.AddressInfo).port; + const rewrite = `url.${remote}.insteadOf`; + gitOk(['config', '--global', '--unset', rewrite], sandbox); + gitOk(['config', '--global', `url.http://127.0.0.1:${port}/team.git.insteadOf`, FAKE_URL], sandbox); + try { + const started = Date.now(); + const r = git(['pull', '-q'], repo); + const elapsed = Date.now() - started; + + expect(r.code, r.output).toBe(0); + expect(r.output).toBe(''); + expect(fs.existsSync(path.join(repo, 'change-1.txt'))).toBe(true); + expect(elapsed).toBeGreaterThanOrEqual(4_000); // the fetch really hung until the cap + expect(elapsed).toBeLessThan(10_000); + } finally { + gitOk(['config', '--global', '--unset', `url.http://127.0.0.1:${port}/team.git.insteadOf`], sandbox); + gitOk(['config', '--global', rewrite, FAKE_URL], sandbox); + server.close(); + for (const socket of sockets) socket.destroy(); + } + await settle(repo); + }); + + it('self mode: git pull brings a changed rule into the checkout with no network from the hook', async () => { + const repo = path.join(sandbox, 'self-project'); + fs.mkdirSync(repo); + fs.writeFileSync(path.join(repo, 'README'), 'x\n'); + gitOk(['init', '-q', '-b', 'main'], repo); + gitOk(['add', '-A'], repo); + gitOk(['commit', '-q', '-m', 'project'], repo); + // init parses the origin as a provider URL; the pull itself uses a local remote. + gitOk(['remote', 'add', 'origin', 'http://127.0.0.1:9/team/self.git'], repo); + const init = teamai(['init', '--self', '--agent', 'claude', '--force'], repo); + expect(init.code, init.output).toBe(0); + gitOk(['remote', 'remove', 'origin'], repo); + withOrigin(repo); + const mate = `${repo}-mate`; + fs.mkdirSync(path.join(mate, '.teamai', 'rules'), { recursive: true }); + fs.writeFileSync(path.join(mate, '.teamai', 'rules', 'self-rule.md'), '# Self rule\n'); + gitOk(['add', '-A'], mate); + gitOk(['commit', '-q', '-m', 'rule'], mate); + gitOk(['push', '-q', 'origin', 'HEAD:main'], mate); + const trace = path.join(sandbox, 'self-trace.log'); + + const r = git(['pull', '-q'], repo, { GIT_TRACE: trace }); + + expect(r.code, r.output).toBe(0); + expect(r.output).toBe(''); + expect(fs.readFileSync(path.join(repo, '.claude', 'rules', 'self-rule.md'), 'utf8')).toContain('# Self rule'); + const traced = fs.readFileSync(trace, 'utf8'); + const fromHook = traced.slice(traced.indexOf('hook-dispatch post-merge')); + expect(fromHook).toMatch(/hook-dispatch post-merge/); + expect(fromHook).not.toMatch(/\b(fetch|upload-pack|ls-remote|push|pull)\b/); + }); + }); + it('with no enabledAgents, creates the tool roots the main checkout has, and only those', () => { const codexProject = project('codex-project', []); fs.mkdirSync(path.join(codexProject, '.codex')); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 24604860d..fae6f3f3c 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -162,16 +162,53 @@ const newWorktreeHandler: HookHandler = { const { pull } = await import('./pull.js'); await pull({ silent: true, inline: true }); // Learnings, reports, sources and the team repo itself refresh after - // `git worktree add` returns, in a pull this process does not wait for. - const { resolveCliEntry } = await import('./builtin-hooks.js'); - const { spawn } = await import('node:child_process'); - spawn(process.execPath, [resolveCliEntry() ?? '', 'pull', '--silent'], { - cwd, detached: true, stdio: 'ignore', windowsHide: true, - }).on('error', (e) => log.debug(`new-worktree: detached pull failed to start: ${e.message}`)).unref(); + // `git worktree add` returns. + await spawnDetachedPull(cwd); return null; }, }; +/** How long `git pull` waits for the post-merge hook's team repo fetch. */ +const POST_MERGE_FETCH_CAP_MS = 5_000; + +/** + * `post-merge` from the git hook (`git pull`): the next session gets what + * changed. With a separate team repo, the team repo is fetched inline within + * POST_MERGE_FETCH_CAP_MS and delivered when its revision moved (the rev fast + * path skips it otherwise); past the cap, and for learnings, reports and + * sources, a detached pull takes over. In single-repo (self) mode the team + * repo is the working tree `git pull` just updated: delivered with no network. + */ +const gitPullHandler: HookHandler = { + name: 'git-pull', + async execute(stdin, _tool, config) { + if (!config || config.scope !== 'project') return null; + const cwd = resolveHookCwd(stdin) ?? process.cwd(); + const { getDataHome, isSelfMode } = await import('./types.js'); + const self = isSelfMode(config); + // teamai's own checkouts; in self mode the team repo is the member's. + if (await isWithin(cwd, self ? [getDataHome(config)] : [getDataHome(config), config.repo.localPath])) return null; + + const { pull } = await import('./pull.js'); + if (self) { + await pull({ silent: true, inline: true }); + return null; + } + await pull({ silent: true, inline: true, fetchTimeoutMs: POST_MERGE_FETCH_CAP_MS }); + await spawnDetachedPull(cwd); + return null; + }, +}; + +/** Start a full `teamai pull --silent` in `cwd` that this process does not wait for. */ +async function spawnDetachedPull(cwd: string): Promise { + const { resolveCliEntry } = await import('./builtin-hooks.js'); + const { spawn } = await import('node:child_process'); + spawn(process.execPath, [resolveCliEntry() ?? '', 'pull', '--silent'], { + cwd, detached: true, stdio: 'ignore', windowsHide: true, + }).on('error', (e) => log.debug(`git hook: detached pull failed to start: ${e.message}`)).unref(); +} + /** Whether `dir` is one of `parents` or inside one (real paths). */ async function isWithin(dir: string, parents: string[]): Promise { const { realpath } = await import('node:fs/promises'); @@ -964,9 +1001,10 @@ export function buildHandlerRegistry(): HandlerRegistration[] { // ─── Git (`--tool git`, see git-hook.ts) ────────── // Inline: the delivery has to land before `git worktree add` returns. Git - // has no hook timeout, so the budget is the detached pull's. `post-merge` - // is installed but has no handler yet. + // has no hook timeout, so the budget is the detached pull's; post-merge + // caps its own fetch. { event: 'post-checkout', matcher: '*', handler: newWorktreeHandler, timeoutMs: PULL_TIMEOUT_MS, requiresConfig: true }, + { event: 'post-merge', matcher: '*', handler: gitPullHandler, timeoutMs: PULL_TIMEOUT_MS, requiresConfig: true }, ]; } diff --git a/src/pull.ts b/src/pull.ts index 7957f7b76..5c8d6b647 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -105,7 +105,7 @@ async function teamFetchedWithinTtl(localConfig: LocalConfig): Promise async function refreshTeamRepo( localConfig: LocalConfig, - options: Pick = {}, + options: Pick = {}, ): Promise<{ label: string; version: string | null; submodulesFailed: boolean; submodulesChanged: boolean }> { if (localConfig.repo.kind === 'http') { const { resolveApiKey } = await import('./api-key.js'); @@ -150,11 +150,16 @@ async function refreshTeamRepo( // another writer could reset/checkout the tree. We must NOT lock here: the lock // is non-reentrant, so re-acquiring it in the same process would fail. // The new-worktree hook reads a recently fetched clone as it is. - if (options.inline && await teamFetchedWithinTtl(localConfig)) { + if (options.inline && options.fetchTimeoutMs === undefined && await teamFetchedWithinTtl(localConfig)) { const version = await getHeadRev(localConfig.repo.localPath).catch(() => null); return { label: 'fetched within the TTL, not refetched', version, submodulesFailed: false, submodulesChanged: false }; } - const result = await pullRepo(localConfig.repo.localPath); + // The post-merge hook caps the fetch: git pull is waiting on it. + const cap = options.fetchTimeoutMs === undefined ? undefined : AbortSignal.timeout(options.fetchTimeoutMs); + const result = await pullRepo(localConfig.repo.localPath, cap).catch((e: unknown) => { + if (cap?.aborted) throw new Error(`team repo fetch exceeded ${options.fetchTimeoutMs} ms, left to the detached pull`); + throw e; + }); await writeJson(teamFetchStamp(localConfig), { lastFetch: new Date().toISOString() }) .catch((e) => log.debug(`Could not record the team repo fetch: ${(e as Error).message}`)); @@ -188,7 +193,7 @@ async function refreshTeamRepo( // The leading status char is `-` while uninitialized and ` ` (or `+` when // the checkout is behind its pin) afterwards, so a changed status string // means the on-disk tree the deploy step reads is not what was cached. - const git = createGit(localConfig.repo.localPath); + const git = createGit(localConfig.repo.localPath, cap); // Only the status read is guarded here: an unavailable/unsupported status // must degrade to "changed" (see below), NOT be reported as an update // failure — the update itself is still allowed to fail into the outer diff --git a/src/types.ts b/src/types.ts index bb05aac92..40cc4bdd8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1073,6 +1073,12 @@ export interface GlobalOptions { * reports, usage reporting and post-pull scripts are left to a full pull. */ inline?: boolean; + /** + * Internal (the post-merge git hook, with `inline`): fetch the team repo + * whatever its fetch stamp says, and give up after this many ms; the scope + * is then not delivered and the detached pull after the hook does it. + */ + fetchTimeoutMs?: number; /** Push a specific skill by path. */ skill?: string; /** Target role namespace (overrides detected namespace). */ diff --git a/src/utils/git.ts b/src/utils/git.ts index 5baab5e10..353125960 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -88,11 +88,11 @@ function spawnsByPath(candidate: string): boolean { * Authentication is handled by the provider's remote URL or by normal Git * facilities such as credential helpers, SSH config, and SSH agents. */ -export function createGit(basePath?: string): SimpleGit { +export function createGit(basePath?: string, abort?: AbortSignal): SimpleGit { if (basePath) { - return simpleGit({ baseDir: basePath, binary: gitBinary() }); + return simpleGit({ baseDir: basePath, binary: gitBinary(), abort }); } - return simpleGit({ binary: gitBinary() }); + return simpleGit({ binary: gitBinary(), abort }); } /** @@ -346,8 +346,8 @@ export async function commitPaths( * uncommitted changes, so the loss is never silent. A failed fetch is re-thrown * so the caller surfaces the real network/auth cause. */ -export async function pullRepo(localPath: string): Promise { - const git = createGit(localPath); +export async function pullRepo(localPath: string, abort?: AbortSignal): Promise { + const git = createGit(localPath, abort); const branch = (await git.revparse(['--abbrev-ref', 'HEAD'])).trim(); try { From 4d5dc64d7ea0b81fced7a933cc4b773ce2bacab5 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 11:36:07 +0200 Subject: [PATCH 06/14] feat(hooks): record git hook failures for doctor and the next pull A failure inside the silent git hook (team fetch failed or hit the cap, partition lock held past the inline wait, handler error) is written to debug.log and recorded in the project partition. doctor names it with its fix and reports whether the hook is installed; the next interactive pull mentions it once; a successful hook pull, detached retry included, clears it. The post-merge inline lock wait is capped at its fetch cap. --- docs/usage-guide.md | 8 +- docs/usage-guide.zh-CN.md | 6 +- skill-data/core/SKILL.md | 5 +- skill-data/core/references/troubleshooting.md | 13 ++ .../e2e/git-hook-new-worktree.test.ts | 100 +++++++++++++ src/doctor.ts | 34 +++++ src/git-hook.ts | 135 ++++++++++++++++-- src/hook-handlers.ts | 51 +++++-- src/pull.ts | 56 +++++++- src/types.ts | 6 + 10 files changed, 380 insertions(+), 34 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 5d6e70be5..5c4286de7 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -185,7 +185,13 @@ After `git pull` (`post-merge`), the hook fetches the team repo, waiting at most and delivers its changes before `git pull` returns; past 5 seconds, and for sources, learnings and reports, the same background pull takes over. In single-repo mode it delivers the knowledge `git pull` just brought, with no network. The hook prints nothing and always exits 0, so a failed pull never -fails the git command. It follows the scope rules below: no project config, or one +fails the git command. A failure inside it (the team repo fetch failed, or stopped at the +5-second cap and the background pull did not finish it; another teamai process held the +project's sync lock longer than the hook waits, 5 seconds after `git pull` and 60 seconds +for a new worktree) is written to `~/.teamai/debug.log` and recorded: `teamai doctor` +names it with its fix, and the next interactive `teamai pull` mentions it once. The +background pull retries, and a hook pull that succeeds clears the record. `teamai doctor` +also reports whether the hook is installed and, when it is not, why. It follows the scope rules below: no project config, or one that cannot be read, means no sync. The command is one `sh` line that runs `teamai hook-dispatch --tool git` with Git's arguments, finding `teamai` through `~/.teamai/bin` as the agent hooks do. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index fc87c0217..c879f133c 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -173,7 +173,11 @@ worktree 的应用,新建一个检出时,该 hook 会创建 `enabledAgents` 切换分支不会触发任何操作。`git pull` 之后(`post-merge`),该 hook 会 fetch 团队仓库(最多等待 5 秒), 并在 `git pull` 返回前交付其变更;超过 5 秒时,以及 source、learnings 与 reports,交给同样的后台 pull。 单仓库模式下,它交付 `git pull` 刚带来的知识,不访问网络。该 hook 不输出任何内容且始终以 0 退出, -因此 pull 失败也不会让 git 命令失败。它遵循下文的 scope 规则:没有项目配置,或项目配置无法读取, +因此 pull 失败也不会让 git 命令失败。hook 内的失败(团队仓库 fetch 失败,或在 5 秒上限处被中止而后台 pull +也未完成;另一个 teamai 进程持有项目的同步锁,超过 hook 的等待时间:`git pull` 之后 5 秒,新 worktree 60 秒) +会写入 `~/.teamai/debug.log` 并被记录:`teamai doctor` 会指出它及其修复方法,下一次交互式 `teamai pull` +会提示一次。后台 pull 会重试,任何一次成功的 hook pull 都会清除该记录。`teamai doctor` 还会报告 hook +是否已安装,未安装时说明原因。它遵循下文的 scope 规则:没有项目配置,或项目配置无法读取, 都不会同步。其命令是一行 `sh`,带着 Git 传入的参数运行 `teamai hook-dispatch --tool git`, 与 Agent hook 一样通过 `~/.teamai/bin` 找到 `teamai`。 diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 087b90729..872016413 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -136,8 +136,9 @@ the command returns, from the team clone as last fetched when that was within 24 h; a full pull then runs in the background. A branch switch does nothing. After `git pull` it fetches the team repo (5 s cap, then the background pull) and delivers; in single-repo mode it delivers what `git pull` brought, offline. It prints nothing and always -exits 0, so a worktree still missing team resources needs a `teamai pull` there; -`git config --local --get-regexp '^hook\.teamai-'` shows whether it is installed. +exits 0; a failure inside it is recorded, and `teamai doctor` names it (`Last git +hook run failed: ...`) with its fix, as does the next interactive `teamai pull`, once. +`teamai doctor` also reports whether the hook is installed, and why not. A team agent (`agents/.yaml`) can set `model: strong`, `model: fast`, or an alias the team defines, instead of one tool's model. The team maps each alias per diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index e9eeac6eb..64089850d 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -67,6 +67,19 @@ This is the #1 onboarding issue. In order: `recall` refuses the same way with `Nothing was searched: : `: no team knowledge was searched, so do not report that the team has none. +## "Last git hook run failed: ..." / a new worktree lacks team resources + +In project scope, teamai's git hook syncs on `git worktree add` and `git pull` +silently and always exits 0, so its failures surface only here: `teamai doctor` +names the last one with its fix, and the next interactive `teamai pull` says it +once. The causes are a team repo fetch that failed or hit the 5 s post-merge +cap without the background pull finishing it, and another teamai process +holding the project's sync lock longer than the hook waits. Run `teamai pull` +in the checkout (after a stuck pull ends, or once the team repo is reachable); +`~/.teamai/debug.log` has the details. If doctor reports `Git hook syncs new +worktrees and git pull` as failing, follow its fix: `teamai pull` installs it, +and Git older than 2.54 has no config hooks. + ## "KEY is not set. Run `teamai env set KEY`" `pull`, `teamai mcp list`, `teamai env list`, `teamai doctor` and diff --git a/src/__tests__/e2e/git-hook-new-worktree.test.ts b/src/__tests__/e2e/git-hook-new-worktree.test.ts index d486f77a6..635fdf712 100644 --- a/src/__tests__/e2e/git-hook-new-worktree.test.ts +++ b/src/__tests__/e2e/git-hook-new-worktree.test.ts @@ -487,6 +487,106 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc }); }); + describe('a failure inside the hook is visible', () => { + /** Make the next post-checkout fetch the team repo, and fail doing it (with the detached retry). */ + const failNextHook = (repo: string): (() => void) => { + fs.writeFileSync(stampOf(repo), JSON.stringify({ lastFetch: new Date(Date.now() - 25 * 3600_000).toISOString() })); + const away = `${remote}.away`; + fs.renameSync(remote, away); + return () => fs.renameSync(away, remote); + }; + + it('doctor reports the hook installed, and missing with the reason', () => { + const repo = project('doctor-hook-project', ['--agent', 'claude']); + expect(teamai(['doctor'], repo).output).toContain('✔ Git hook syncs new worktrees and git pull'); + + gitOk(['config', '--local', '--unset', 'hook.teamai-post-checkout.command'], repo); + const missing = teamai(['doctor'], repo).output; + + expect(missing).toContain('✖ Git hook syncs new worktrees and git pull'); + expect(missing).toMatch(/not in this repository's git config.*Run `teamai pull`/); + }); + + it('git worktree add exits 0; doctor names the failure; the next interactive pull mentions it once', async () => { + const repo = project('fail-project', ['--agent', 'claude']); + await settle(repo); + const restore = failNextHook(repo); + let wt: { dir: string } & Run; + try { + wt = worktreeAdd(repo, 'wt-fail'); + expect(wt.code).toBe(0); + expect(wt.output).toBe(''); + await settle(repo); + } finally { + restore(); + } + + const doctor = teamai(['doctor'], repo).output; + expect(doctor).toMatch(/✖ Last git hook run failed: post-checkout could not fetch the team repo/); + expect(doctor).toMatch(/→ .*teamai pull/); + + const first = teamai(['pull'], wt.dir); + expect(first.output).toMatch(/Last git hook run failed: post-checkout could not fetch the team repo/); + const second = teamai(['pull'], wt.dir); + expect(second.output).not.toMatch(/git hook run failed/); + expect(teamai(['doctor'], repo).output).toContain('✔ No git hook failure recorded'); + }); + + it('a partition lock another pull holds: post-merge waits no longer than its cap and records why it skipped', async () => { + const repo = project('locked-project', ['--agent', 'claude']); + const bare = `${repo}.git`; + gitOk(['clone', '-q', '--bare', repo, bare], sandbox); + gitOk(['remote', 'add', 'origin', bare], repo); + gitOk(['fetch', '-q', 'origin'], repo); + gitOk(['branch', '-q', '-u', 'origin/main'], repo); + const mate = `${repo}-mate`; + gitOk(['clone', '-q', bare, mate], sandbox); + fs.writeFileSync(path.join(mate, 'change.txt'), '1\n'); + gitOk(['add', '-A'], mate); + gitOk(['commit', '-q', '-m', 'change'], mate); + gitOk(['push', '-q', 'origin', 'HEAD:main'], mate); + await settle(repo); + // A live holder: this test process. + const lock = path.join(partitionOf(repo), '.sync-lock'); + fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), owner: 'e2e' })); + try { + const started = Date.now(); + const r = git(['pull', '-q'], repo); + const elapsed = Date.now() - started; + + expect(r.code, r.output).toBe(0); + expect(r.output).toBe(''); + expect(elapsed).toBeLessThan(15_000); + // The detached pull it hands over to meets the same lock and skips. + await new Promise((resolve) => setTimeout(resolve, 3_000)); + } finally { + fs.rmSync(lock, { force: true }); + } + + const doctor = teamai(['doctor'], repo).output; + expect(doctor).toMatch(/✖ Last git hook run failed: post-merge skipped its sync: another teamai process held the project's sync lock/); + }, 60_000); + + it('a successful hook run clears the recorded failure', async () => { + const repo = project('recover-project', ['--agent', 'claude']); + await settle(repo); + const restore = failNextHook(repo); + try { + worktreeAdd(repo, 'wt-recover-fail'); + await settle(repo); + } finally { + restore(); + } + expect(teamai(['doctor'], repo).output).toMatch(/✖ Last git hook run failed/); + + const wt = worktreeAdd(repo, 'wt-recover-ok'); + expect(wt.code).toBe(0); + await settle(repo); + + expect(teamai(['doctor'], repo).output).toContain('✔ No git hook failure recorded'); + }); + }); + it('with no enabledAgents, creates the tool roots the main checkout has, and only those', () => { const codexProject = project('codex-project', []); fs.mkdirSync(path.join(codexProject, '.codex')); diff --git a/src/doctor.ts b/src/doctor.ts index 5892c955c..8385ae077 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -514,6 +514,7 @@ export async function buildChecks(ctx: DoctorContext, stage: CheckStage = 'docto fix: 'Run `teamai pull` to publish them. If they stay queued, check that you ' + 'can push to the team repo (run with --verbose to see the push error).', }, + ...await buildGitHookChecks(localConfig, stage), ...buildToolRootChecks(localConfig, teamConfig), ...await buildEnabledToolChecks(ctx), ...await buildHookChecks(toolPaths, hookToolPaths, baseDir, localConfig), @@ -535,6 +536,39 @@ export async function buildChecks(ctx: DoctorContext, stage: CheckStage = 'docto return checks; } +/** + * Project scope: whether teamai's git hook is installed (`doctor` only: pull + * installs it, and a member on an old git would hear it after every pull), and + * the failure its last silent run recorded (git-hook.ts), which `pull` + * mentions itself. A project root outside git has no hook to report. + */ +async function buildGitHookChecks(localConfig: LocalConfig, stage: CheckStage): Promise { + if (localConfig.scope !== 'project' || !localConfig.projectRoot) return []; + const { gitHookStatus, describeMissingGitHook, readGitHookFailure, describeGitHookFailure } = await import('./git-hook.js'); + // A project root that no longer exists cannot be asked (git refuses the cwd). + const status = stage === 'doctor' ? await gitHookStatus(localConfig.projectRoot).catch(() => null) : null; + const failure = await readGitHookFailure(localConfig); + const report = failure ? describeGitHookFailure(failure) : null; + const installed: Check[] = !status || (!status.installed && status.reason === 'not-a-repository') ? [] : [{ + name: 'Git hook syncs new worktrees and git pull', + source: 'local', + check: async () => status.installed, + ...(status.installed ? {} : { fix: describeMissingGitHook(status) }), + }]; + return [ + ...installed, + report + ? { + name: `Last git hook run failed: ${report.message}`, + source: 'local', + reportedByPull: 'git-hook-failure', + check: async () => false, + fix: report.fix, + } + : { name: 'No git hook failure recorded', source: 'local', check: async () => true }, + ]; +} + /** * Run every check once, in registry order. `onResult` reports each one as it * lands, so the human rendering keeps streaming while a slow check (a provider diff --git a/src/git-hook.ts b/src/git-hook.ts index 9c29d0cca..a354a5e37 100644 --- a/src/git-hook.ts +++ b/src/git-hook.ts @@ -17,7 +17,10 @@ */ import { ensureTeamaiWrapper, TEAMAI_BIN_DIR } from './builtin-hooks.js'; +import path from 'node:path'; +import { getDataHome, type LocalConfig } from './types.js'; import { execCommand } from './utils/exec.js'; +import { readJson, remove, writeJson } from './utils/fs.js'; import { log } from './utils/logger.js'; export const GIT_HOOK_EVENTS = ['post-checkout', 'post-merge'] as const; @@ -44,12 +47,53 @@ export type GitHookInstall = | { installed: true; changed: boolean } | { installed: false; reason: 'old-git' | 'not-a-repository' }; +export type GitHookStatus = + | { installed: true } + | { installed: false; reason: 'old-git'; gitVersion: string } + | { installed: false; reason: 'not-a-repository' | 'not-configured' }; + +type Git = (args: string[]) => ReturnType; +const gitIn = (repoDir: string): Git => (args) => execCommand('git', args, { cwd: repoDir, timeoutMs: 10_000 }); + +async function eventsToWrite(git: Git): Promise { + const stale: GitHookEvent[] = []; + for (const event of GIT_HOOK_EVENTS) { + const key = `hook.${hookName(event)}`; + const current = (await git(['config', '--local', '--get', `${key}.command`])).stdout.trim(); + const events = (await git(['config', '--local', '--get-all', `${key}.event`])).stdout.trim(); + if (current !== gitHookCommand(event) || events !== event) stale.push(event); + } + return stale; +} + +/** Whether the hook is in the local config of the repository holding `repoDir`, and why not. */ +export async function gitHookStatus(repoDir: string): Promise { + const git = gitIn(repoDir); + const version = (await git(['--version'])).stdout.trim(); + if (!supportsConfigHooks(version)) return { installed: false, reason: 'old-git', gitVersion: version }; + if ((await git(['rev-parse', '--git-dir'])).code !== 0) return { installed: false, reason: 'not-a-repository' }; + return (await eventsToWrite(git)).length === 0 ? { installed: true } : { installed: false, reason: 'not-configured' }; +} + +/** What `doctor` says about a hook that is not installed: the cause, then the next step. */ +export function describeMissingGitHook(status: Exclude): string { + switch (status.reason) { + case 'old-git': + return `${status.gitVersion || 'This git'} has no config-based hooks (Git 2.54 or later), so new worktrees and ` + + '`git pull` get the team\'s resources only at the next session. Upgrade git, then run `teamai pull`.'; + case 'not-a-repository': + return 'The project root is not a git repository, so there is no git event to hook.'; + case 'not-configured': + return 'The teamai hooks are not in this repository\'s git config. Run `teamai pull` to install them.'; + } +} + /** * Write (or refresh) the hook into the local config of the repository holding * `repoDir`. Idempotent: an up-to-date hook is left untouched. */ export async function installGitHook(repoDir: string): Promise { - const git = (args: string[]) => execCommand('git', args, { cwd: repoDir, timeoutMs: 10_000 }); + const git = gitIn(repoDir); if (!supportsConfigHooks((await git(['--version'])).stdout)) return { installed: false, reason: 'old-git' }; if ((await git(['rev-parse', '--git-dir'])).code !== 0) return { installed: false, reason: 'not-a-repository' }; @@ -57,19 +101,14 @@ export async function installGitHook(repoDir: string): Promise { // that runs git has no login PATH. ensureTeamaiWrapper(); - let changed = false; - for (const event of GIT_HOOK_EVENTS) { + const stale = await eventsToWrite(git); + for (const event of stale) { const key = `hook.${hookName(event)}`; - const command = gitHookCommand(event); - const current = (await git(['config', '--local', '--get', `${key}.command`])).stdout.trim(); - const events = (await git(['config', '--local', '--get-all', `${key}.event`])).stdout.trim(); - if (current === command && events === event) continue; - await ok(git(['config', '--local', `${key}.command`, command]), key); + await ok(git(['config', '--local', `${key}.command`, gitHookCommand(event)]), key); await ok(git(['config', '--local', '--replace-all', `${key}.event`, event]), key); - changed = true; } - if (changed) log.debug(`git hook: installed teamai hooks in ${repoDir}`); - return { installed: true, changed }; + if (stale.length > 0) log.debug(`git hook: installed teamai hooks in ${repoDir}`); + return { installed: true, changed: stale.length > 0 }; } async function ok(result: ReturnType, key: string): Promise { @@ -123,3 +162,77 @@ const REPOSITORY_ENV = [ export function clearGitHookRepositoryEnv(env: NodeJS.ProcessEnv = process.env): void { for (const name of REPOSITORY_ENV) delete env[name]; } + +// ─── Recorded failures ───────────────────────────────── +// +// The hook is silent and exits 0, so a failure inside it is kept here, in the +// project partition every worktree shares, until someone is told: `doctor` +// names it, the next interactive `pull` mentions it once and drops it, and the +// next hook-started pull that succeeds (the detached retry included) drops it. + +export type GitHookFailure = + | { kind: 'fetch-failed'; event: GitHookEvent; at: string; error: string } + | { kind: 'fetch-timeout'; event: GitHookEvent; at: string; capMs: number } + | { kind: 'lock-held'; event: GitHookEvent; at: string; waitedMs: number } + | { kind: 'hook-error'; event: GitHookEvent; at: string; error: string }; + +/** A failure as the member reads it: what happened and why, then the next step. */ +export interface GitHookFailureReport { + message: string; + fix: string; +} + +function failureFile(config: LocalConfig): string { + return path.join(getDataHome(config), 'git-hook-failure.json'); +} + +export function isGitHookEvent(value: unknown): value is GitHookEvent { + return (GIT_HOOK_EVENTS as readonly unknown[]).includes(value); +} + +/** Keep `failure` for `doctor` and the next pull, and write it to debug.log. */ +export async function recordGitHookFailure(config: LocalConfig, failure: GitHookFailure): Promise { + log.persist(`git hook: ${describeGitHookFailure(failure).message}`); + await writeJson(failureFile(config), failure) + .catch((e) => log.persist(`git hook: could not record the failure: ${(e as Error).message}`)); +} + +export async function readGitHookFailure(config: LocalConfig): Promise { + const failure = await readJson(failureFile(config)).catch(() => null); + return failure && isGitHookEvent(failure.event) ? failure : null; +} + +export async function clearGitHookFailure(config: LocalConfig): Promise { + await remove(failureFile(config)).catch(() => {}); +} + +export function describeGitHookFailure(failure: GitHookFailure): GitHookFailureReport { + const when = `(${failure.at})`; + // git's stderr runs over several lines; the first names the cause, debug.log keeps the rest. + const firstLine = (error: string) => error.trim().split('\n')[0]; + switch (failure.kind) { + case 'fetch-failed': + return { + message: `${failure.event} could not fetch the team repo ${when}: ${firstLine(failure.error)}`, + fix: 'The worktree may lack the team\'s latest resources. Check that you can reach the team repo, ' + + 'then run `teamai pull`.', + }; + case 'fetch-timeout': + return { + message: `${failure.event} stopped the team repo fetch at its ${failure.capMs} ms cap ${when}, ` + + 'and the pull it handed over to has not completed it', + fix: 'Check that you can reach the team repo, then run `teamai pull`.', + }; + case 'lock-held': + return { + message: `${failure.event} skipped its sync: another teamai process held the project's sync lock ` + + `for the ${failure.waitedMs} ms it waits ${when}`, + fix: 'Run `teamai pull` once that process finishes. If a teamai pull is stuck, stop it first.', + }; + case 'hook-error': + return { + message: `${failure.event} failed ${when}: ${firstLine(failure.error)}`, + fix: 'Run `teamai pull` to sync; ~/.teamai/debug.log has the details.', + }; + } +} diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index fae6f3f3c..152a8c81e 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -157,13 +157,15 @@ const newWorktreeHandler: HookHandler = { const { getDataHome } = await import('./types.js'); if (await isWithin(cwd, [getDataHome(config), config.repo.localPath])) return null; - const { createProjectToolRoots } = await import('./project-agent-root.js'); - await createProjectToolRoots({ cwd }); - const { pull } = await import('./pull.js'); - await pull({ silent: true, inline: true }); + await recordingFailure(config, 'post-checkout', async () => { + const { createProjectToolRoots } = await import('./project-agent-root.js'); + await createProjectToolRoots({ cwd }); + const { pull } = await import('./pull.js'); + await pull({ silent: true, inline: true, gitHook: 'post-checkout' }); + }); // Learnings, reports, sources and the team repo itself refresh after - // `git worktree add` returns. - await spawnDetachedPull(cwd); + // `git worktree add` returns; it also retries what failed above. + await spawnDetachedPull(cwd, 'post-checkout'); return null; }, }; @@ -190,22 +192,41 @@ const gitPullHandler: HookHandler = { if (await isWithin(cwd, self ? [getDataHome(config)] : [getDataHome(config), config.repo.localPath])) return null; const { pull } = await import('./pull.js'); - if (self) { - await pull({ silent: true, inline: true }); - return null; - } - await pull({ silent: true, inline: true, fetchTimeoutMs: POST_MERGE_FETCH_CAP_MS }); - await spawnDetachedPull(cwd); + await recordingFailure(config, 'post-merge', () => pull(self + ? { silent: true, inline: true, gitHook: 'post-merge' } + : { silent: true, inline: true, gitHook: 'post-merge', fetchTimeoutMs: POST_MERGE_FETCH_CAP_MS })); + if (!self) await spawnDetachedPull(cwd, 'post-merge'); return null; }, }; -/** Start a full `teamai pull --silent` in `cwd` that this process does not wait for. */ -async function spawnDetachedPull(cwd: string): Promise { +/** + * Run the inline pass of a git hook; what it throws is recorded (the hook is + * silent), not raised. + */ +async function recordingFailure( + config: LocalConfig, + event: 'post-checkout' | 'post-merge', + pass: () => Promise, +): Promise { + try { + await pass(); + } catch (e) { + const { recordGitHookFailure } = await import('./git-hook.js'); + await recordGitHookFailure(config, { kind: 'hook-error', event, at: new Date().toISOString(), error: (e as Error).message }); + } +} + +/** + * Start a full `teamai pull --silent` in `cwd` that this process does not wait + * for. TEAMAI_GIT_HOOK makes it record its failure, and clear the record when it + * succeeds. + */ +async function spawnDetachedPull(cwd: string, event: 'post-checkout' | 'post-merge'): Promise { const { resolveCliEntry } = await import('./builtin-hooks.js'); const { spawn } = await import('node:child_process'); spawn(process.execPath, [resolveCliEntry() ?? '', 'pull', '--silent'], { - cwd, detached: true, stdio: 'ignore', windowsHide: true, + cwd, detached: true, stdio: 'ignore', windowsHide: true, env: { ...process.env, TEAMAI_GIT_HOOK: event }, }).on('error', (e) => log.debug(`git hook: detached pull failed to start: ${e.message}`)).unref(); } diff --git a/src/pull.ts b/src/pull.ts index 5c8d6b647..e4d6edd12 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -97,6 +97,9 @@ function teamFetchStamp(localConfig: LocalConfig): string { /** How long an inline pull waits for another pull's partition lock. */ const INLINE_LOCK_WAIT_MS = 60_000; +/** `Error.name` of a team repo fetch stopped at `fetchTimeoutMs`. */ +const TEAM_FETCH_TIMEOUT = 'TeamFetchTimeout'; + async function teamFetchedWithinTtl(localConfig: LocalConfig): Promise { const stamp = await readJson<{ lastFetch: string }>(teamFetchStamp(localConfig)); const elapsed = stamp ? Date.now() - new Date(stamp.lastFetch).getTime() : NaN; @@ -157,7 +160,11 @@ async function refreshTeamRepo( // The post-merge hook caps the fetch: git pull is waiting on it. const cap = options.fetchTimeoutMs === undefined ? undefined : AbortSignal.timeout(options.fetchTimeoutMs); const result = await pullRepo(localConfig.repo.localPath, cap).catch((e: unknown) => { - if (cap?.aborted) throw new Error(`team repo fetch exceeded ${options.fetchTimeoutMs} ms, left to the detached pull`); + if (cap?.aborted) { + const timeout = new Error(`team repo fetch exceeded ${options.fetchTimeoutMs} ms, left to the detached pull`); + timeout.name = TEAM_FETCH_TIMEOUT; + throw timeout; + } throw e; }); await writeJson(teamFetchStamp(localConfig), { lastFetch: new Date().toISOString() }) @@ -919,7 +926,7 @@ async function pullForScope( revisionField?: 'lastPullRev' | 'lastInheritedPullRev'; } = {}, /** Set to `{ completed: true }` on a real (non-dry-run) sync. See pull(). */ - result?: { completed: boolean; docsSyncFailed: boolean; agentModelsHeld: boolean }, + result?: { completed: boolean; docsSyncFailed: boolean; agentModelsHeld: boolean; teamRepoFailed?: boolean }, /** Collects this scope's env resolution for the stages after it (see resolvePullEnv). */ teamEnvs?: Map, ): Promise { @@ -956,6 +963,14 @@ async function pullForScope( const reason = `[${scopeLabel}] Pull failed: ${(e as Error).message}`; pullSpin.fail(reason); log.persist(reason); + if (result) result.teamRepoFailed = true; + if (options.gitHook && localConfig.scope === 'project') { + const { recordGitHookFailure } = await import('./git-hook.js'); + const at = new Date().toISOString(); + await recordGitHookFailure(localConfig, (e as Error).name === TEAM_FETCH_TIMEOUT + ? { kind: 'fetch-timeout', event: options.gitHook, at, capMs: options.fetchTimeoutMs ?? 0 } + : { kind: 'fetch-failed', event: options.gitHook, at, error: (e as Error).message }); + } return; } @@ -2169,6 +2184,18 @@ async function reinjectLegacyHooks(localConfig: LocalConfig): Promise { log.debug('Hooks migrated to dispatch format'); } +/** Say the failure the git hook recorded, then drop it: an interactive pull says it once. */ +async function mentionGitHookFailure(config: LocalConfig, reported: Set): Promise { + const { readGitHookFailure, clearGitHookFailure, describeGitHookFailure } = await import('./git-hook.js'); + const failure = await readGitHookFailure(config); + if (!failure) return; + const { message, fix } = describeGitHookFailure(failure); + log.warn(`Last git hook run failed: ${message}`); + log.dim(` → ${fix}`); + reported.add('git-hook-failure'); + await clearGitHookFailure(config); +} + /** * Main pull entry point. * @@ -2191,12 +2218,16 @@ export async function pull( // Warnings about the team repo are said once per pull, not once per scope or // per resolution, and every pull says them again. resetWarnOnce(); + // A pull a git hook started: inline, or the detached one it hands over to. + const { isGitHookEvent } = await import('./git-hook.js'); + const hookEnv = process.env.TEAMAI_GIT_HOOK; + if (!options.gitHook && isGitHookEvent(hookEnv)) options = { ...options, gitHook: hookEnv }; // What the scopes below say in their own words, so the post-pull pass does // not repeat it. Owned here rather than at module scope so nothing survives // into another call. const reported = new Set(); // A later successful scope must not hide an earlier docs failure (or vice versa). - const syncResult = { completed: false, docsSyncFailed: false, agentModelsHeld: false }; + const syncResult = { completed: false, docsSyncFailed: false, agentModelsHeld: false, teamRepoFailed: false }; // Each scope's env, resolved once by its env stage (resolvePullEnv). const teamEnvs = new Map(); @@ -2237,7 +2268,10 @@ export async function pull( // Inline (new-worktree hook), a skipped scope is a worktree without the // team's resources, and the holder is often the detached pull of the // worktree created just before: wait for it, within the hook's budget. - const waitUntil = options.inline ? Date.now() + INLINE_LOCK_WAIT_MS : 0; + // Post-merge caps it like its fetch: `git pull` waits on it, and the holder + // may be a detached pull hung on the network. + const lockWaitMs = options.fetchTimeoutMs ?? INLINE_LOCK_WAIT_MS; + const waitUntil = options.inline ? Date.now() + lockWaitMs : 0; let acquired = await acquireLock(lock, { dryRun: options.dryRun }); while (!acquired && Date.now() < waitUntil) { await new Promise((resolve) => setTimeout(resolve, 200)); @@ -2252,6 +2286,14 @@ export async function pull( // once the other process finishes syncs it normally. log.info(`[${config.scope}] sync in progress elsewhere — skipped (another pull/push holds the lock)`); contended.add(config); + // A detached hook pull skipping is fine: the holder syncs. An inline one + // leaves the checkout without what the next session reads. + if (options.inline && options.gitHook && config.scope === 'project') { + const { recordGitHookFailure } = await import('./git-hook.js'); + await recordGitHookFailure(config, { + kind: 'lock-held', event: options.gitHook, at: new Date().toISOString(), waitedMs: lockWaitMs, + }); + } return false; }; @@ -2324,9 +2366,15 @@ export async function pull( // 3. Project scope. if (projectConfig) { + // The git hook runs silently; what failed in it is said here, once. + if (!options.silent && !options.dryRun) await mentionGitHookFailure(projectConfig, reported); try { if (await lockScope(projectConfig)) { await pullForScope(projectConfig, options, reported, {}, syncResult, teamEnvs); + if (options.gitHook && !syncResult.teamRepoFailed) { + const { clearGitHookFailure } = await import('./git-hook.js'); + await clearGitHookFailure(projectConfig); + } } } catch (e) { log.warn(`Project-scope pull error: ${(e as Error).message}`); diff --git a/src/types.ts b/src/types.ts index 40cc4bdd8..a426a67a5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1079,6 +1079,12 @@ export interface GlobalOptions { * is then not delivered and the detached pull after the hook does it. */ fetchTimeoutMs?: number; + /** + * Internal (`pull` only): the git hook event that started this pull, inline + * or detached (`TEAMAI_GIT_HOOK` in the environment). Its failures are + * recorded for `doctor` and the next interactive pull; its success clears them. + */ + gitHook?: 'post-checkout' | 'post-merge'; /** Push a specific skill by path. */ skill?: string; /** Target role namespace (overrides detected namespace). */ From 9a94ab452ca3fd5a4c4284abf42345a2aeaacadf Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 11:50:47 +0200 Subject: [PATCH 07/14] feat(hooks): fall back to a .git/hooks block on older Git, advise under core.hooksPath --- docs/usage-guide.md | 14 ++- docs/usage-guide.zh-CN.md | 12 +- skill-data/core/SKILL.md | 3 +- skill-data/core/references/troubleshooting.md | 8 +- src/__tests__/git-hook.test.ts | 118 +++++++++++++++++- src/__tests__/pull-unreadable-config.test.ts | 15 +++ src/git-hook.ts | 94 ++++++++++++-- src/pull.ts | 2 + 8 files changed, 253 insertions(+), 13 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 5c4286de7..eb1d48905 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -172,7 +172,7 @@ chosen or opened in this project. A new worktree does not wait for that first session. In project scope, `teamai init` and `teamai pull` install a git hook in the repository's local git config, shared by every worktree: `hook.teamai-post-checkout` and `hook.teamai-post-merge` (Git 2.54 or -later; with older Git none is installed). Git runs it beside any `core.hooksPath` hook +later). Git runs it beside any `core.hooksPath` hook manager and any `.git/hooks` script. When `git worktree add`, or an app that creates worktrees, makes a new checkout, the hook creates the project roots of `enabledAgents` (when that is empty, the roots the main checkout has) and pulls into @@ -196,6 +196,18 @@ that cannot be read, means no sync. The command is one `sh` line that runs `teamai hook-dispatch --tool git` with Git's arguments, finding `teamai` through `~/.teamai/bin` as the agent hooks do. +With Git older than 2.54 and no `core.hooksPath`, teamai instead adds a block between +`# >>> teamai git hook` and `# <<< teamai git hook <<<` markers to `.git/hooks/post-checkout` +and `.git/hooks/post-merge`, right after the shebang, creating the script when there is +none; the script's other lines are kept. The block runs the same command, silently, and +does not change the script's exit status. With `core.hooksPath` set (a hook manager), or +a hook script that is not a shell script, teamai writes nothing, and `teamai doctor` +advises: upgrade Git to 2.54 or later; or, if the team agrees to commit it, run +`command -v teamai >/dev/null 2>&1 && teamai hook-dispatch --tool git "$@" >/dev/null 2>&1 || true` +from the post-checkout and post-merge hooks your manager defines (with `post-checkout` or +`post-merge` as ``), wrapped in `sh -c '...'` when its config is not a shell script. +That line does nothing on a machine without teamai. + > **Upgrading from an older teamai?** The first `teamai init` / `pull` / `push` / > `contribute` (or `import --from-mr`) after upgrading automatically migrates an existing `/.teamai/` into the partition > (copy → verify → atomic switch), then leaves the old directory as `/.teamai.bak/` diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index c879f133c..15cddcfc9 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -164,7 +164,7 @@ teamai init https://github.com/yourorg/yourrepo 新 worktree 不必等到第一次会话。在项目 scope 下,`teamai init` 与 `teamai pull` 会在仓库的本地 git 配置中安装一个 git hook,所有 worktree 共用:`hook.teamai-post-checkout` 与 -`hook.teamai-post-merge`(需要 Git 2.54 或更高版本;更旧的 Git 不会安装)。Git 会在任何 +`hook.teamai-post-merge`(需要 Git 2.54 或更高版本)。Git 会在任何 `core.hooksPath` hook 管理器和 `.git/hooks` 脚本之外一并运行它。当 `git worktree add`,或会创建 worktree 的应用,新建一个检出时,该 hook 会创建 `enabledAgents` 的项目根目录(为空时,取主检出已有的根目录), 并在命令返回前向该 worktree 执行 pull,因此其中的第一次会话就已具备团队的 skill、rule 与 MCP 服务器。 @@ -181,6 +181,16 @@ worktree 的应用,新建一个检出时,该 hook 会创建 `enabledAgents` 都不会同步。其命令是一行 `sh`,带着 Git 传入的参数运行 `teamai hook-dispatch --tool git`, 与 Agent hook 一样通过 `~/.teamai/bin` 找到 `teamai`。 +Git 低于 2.54 且未设置 `core.hooksPath` 时,teamai 改为在 `.git/hooks/post-checkout` 与 +`.git/hooks/post-merge` 的 shebang 之后插入一段位于 `# >>> teamai git hook` 与 `# <<< teamai git hook <<<` +标记之间的代码块(脚本不存在时会创建),脚本的其他行保持不变。该代码块运行同一条命令,不输出任何内容, +也不改变脚本的退出码。设置了 `core.hooksPath`(hook 管理器),或 hook 脚本不是 shell 脚本时,teamai +不写入任何内容,`teamai doctor` 会建议:将 Git 升级到 2.54 或更高版本;或者,如果团队同意提交它,在管理器定义的 +post-checkout 与 post-merge hook 中运行 +`command -v teamai >/dev/null 2>&1 && teamai hook-dispatch --tool git "$@" >/dev/null 2>&1 || true` +(`` 分别为 `post-checkout` 与 `post-merge`),管理器的配置不是 shell 脚本时用 `sh -c '...'` 包裹。 +在没有 teamai 的机器上,这一行什么也不做。 + > **从旧版 teamai 升级?** 升级后首次执行 `teamai init` / `pull` / `push` / `contribute` > (或 `import --from-mr`)会自动把已有的 > `/.teamai/` 迁移进分区(复制 → 校验 → 原子切换),并把旧目录保留为 diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 872016413..9a5f23a77 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -129,7 +129,8 @@ new worktree's first pull, still overwrite: nothing is recorded yet. In project scope, `init` and `pull` also install a git hook in the repository's local git config (`hook.teamai-post-checkout`, `hook.teamai-post-merge`; Git -2.54+), beside any `core.hooksPath` manager or `.git/hooks` script. When a +2.54+; older Git without `core.hooksPath` gets a marked block in `.git/hooks/` +scripts, and with it `teamai doctor` advises), beside any `core.hooksPath` manager or `.git/hooks` script. When a worktree is created (`git worktree add`, or an app), it creates the project roots of `enabledAgents` (else the ones the main checkout has) and pulls into it before the command returns, from the team clone as last fetched when that was within diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 64089850d..759f3a22c 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -77,8 +77,12 @@ cap without the background pull finishing it, and another teamai process holding the project's sync lock longer than the hook waits. Run `teamai pull` in the checkout (after a stuck pull ends, or once the team repo is reachable); `~/.teamai/debug.log` has the details. If doctor reports `Git hook syncs new -worktrees and git pull` as failing, follow its fix: `teamai pull` installs it, -and Git older than 2.54 has no config hooks. +worktrees and git pull` as failing, follow its fix: `teamai pull` installs it. +Git older than 2.54 has no config hooks: teamai then adds a marked block to +`.git/hooks/post-checkout` and `post-merge`, unless `core.hooksPath` is set (or a +hook there is not a shell script), in which case doctor's fix says to upgrade Git +or, if the team agrees, to commit its guarded `command -v teamai ... || true` +line into the manager's post-checkout and post-merge hooks. ## "KEY is not set. Run `teamai env set KEY`" diff --git a/src/__tests__/git-hook.test.ts b/src/__tests__/git-hook.test.ts index d063ebf43..490d25f66 100644 --- a/src/__tests__/git-hook.test.ts +++ b/src/__tests__/git-hook.test.ts @@ -4,7 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { installGitHook } from '../git-hook.js'; +import { describeMissingGitHook, gitHookStatus, guardedGitHookLine, installGitHook } from '../git-hook.js'; const gitVersion = (): [number, number] => { const m = /(\d+)\.(\d+)/.exec(execFileSync('git', ['--version'], { encoding: 'utf8' })); @@ -109,3 +109,119 @@ describe.skipIf(!configHooks)('teamai git hook in the repository config', () => expect(missing.stdout + missing.stderr).toBe(''); }); }); + +describe('teamai hook script on a Git without config hooks', () => { + let sandbox: string; + let repo: string; + let home: string; + let saved: { PATH?: string; HOME?: string }; + + const run = (args: string[], cwd = repo) => + spawnSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, ...GIT_ENV } }); + const hookFile = (event: string) => path.join(repo, '.git', 'hooks', event); + const calls = () => { + const file = path.join(sandbox, 'calls.txt'); + return fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim().split('\n') : []; + }; + + beforeEach(() => { + sandbox = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-git-hook-old-'))); + repo = path.join(sandbox, 'repo'); + home = path.join(sandbox, 'home'); + fs.mkdirSync(repo); + fs.mkdirSync(home); + // Old Git, simulated where teamai reads the version: a `git` on PATH that + // reports 2.39 and hands every other command to the real one. + const realGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); + const shim = path.join(sandbox, 'old-git'); + fs.mkdirSync(shim); + fs.writeFileSync( + path.join(shim, 'git'), + `#!/bin/sh\nif [ "$1" = --version ]; then echo "git version 2.39.5"; exit 0; fi\nexec "${realGit}" "$@"\n`, + { mode: 0o755 }, + ); + saved = { PATH: process.env.PATH, HOME: process.env.HOME }; + process.env.PATH = `${shim}${path.delimiter}${process.env.PATH}`; + process.env.HOME = home; + run(['init', '-q', '-b', 'main']); + run(['commit', '-q', '--allow-empty', '-m', 'init']); + }); + + afterEach(() => { + process.env.PATH = saved.PATH; + process.env.HOME = saved.HOME; + fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + const fakeTeamai = () => { + fs.mkdirSync(path.join(home, '.teamai', 'bin'), { recursive: true }); + fs.writeFileSync( + path.join(home, '.teamai', 'bin', 'teamai'), + `#!/bin/sh\necho "$@" >> "${path.join(sandbox, 'calls.txt')}"\necho noise\nexit 3\n`, + { mode: 0o755 }, + ); + }; + + it('adds a marked block to an existing hook script without changing its other lines', async () => { + const original = '#!/bin/sh\n# the team\'s own hook\necho mine >> "$0.log"\n'; + fs.writeFileSync(hookFile('post-checkout'), original, { mode: 0o755 }); + + expect(await installGitHook(repo)).toEqual({ installed: true, changed: true }); + const text = fs.readFileSync(hookFile('post-checkout'), 'utf8'); + const block = /# >>> teamai[^\n]*\n[\s\S]*?# <<< teamai[^\n]*\n/.exec(text); + expect(block).not.toBeNull(); + expect(text.replace(block![0], '')).toBe(original); + // A second run leaves it alone. + expect(await installGitHook(repo)).toEqual({ installed: true, changed: false }); + expect(fs.readFileSync(hookFile('post-checkout'), 'utf8')).toBe(text); + // post-merge had no script: a new executable one. + expect(fs.statSync(hookFile('post-merge')).mode & 0o111).not.toBe(0); + expect(await gitHookStatus(repo)).toEqual({ installed: true }); + }); + + it('runs the dispatcher with Git\'s arguments, silently, keeping the script\'s own work and exit status', async () => { + fs.writeFileSync(hookFile('post-checkout'), '#!/bin/sh\necho mine >> "$0.log"\n', { mode: 0o755 }); + await installGitHook(repo); + fakeTeamai(); + + const r = run(['worktree', 'add', '-q', path.join(sandbox, 'wt')]); + + expect(r.status).toBe(0); + expect(r.stdout + r.stderr).toBe(''); + expect(calls()).toHaveLength(1); + expect(calls()[0]).toMatch(/^hook-dispatch post-checkout --tool git 0+ [0-9a-f]{40} 1$/); + expect(fs.readFileSync(`${hookFile('post-checkout')}.log`, 'utf8')).toBe('mine\n'); + }); + + it('leaves a core.hooksPath manager\'s files alone, and doctor advises upgrading or a guarded line', async () => { + const managed = path.join(sandbox, 'managed'); + fs.mkdirSync(managed); + fs.writeFileSync(path.join(managed, 'post-checkout'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + run(['config', 'core.hooksPath', managed]); + + expect(await installGitHook(repo)).toEqual({ installed: false, reason: 'hooks-path' }); + expect(fs.readdirSync(managed)).toEqual(['post-checkout']); + expect(fs.readFileSync(path.join(managed, 'post-checkout'), 'utf8')).toBe('#!/bin/sh\nexit 0\n'); + expect(fs.existsSync(hookFile('post-checkout'))).toBe(false); + + const status = await gitHookStatus(repo); + expect(status).toMatchObject({ installed: false, reason: 'hooks-path' }); + const advice = describeMissingGitHook(status as Exclude); + const upgrade = advice.indexOf('Upgrade Git'); + const guarded = advice.indexOf('command -v teamai >/dev/null 2>&1 && teamai hook-dispatch post-checkout --tool git "$@"'); + expect(upgrade).toBeGreaterThan(-1); + expect(guarded).toBeGreaterThan(upgrade); + expect(advice).toContain('teamai hook-dispatch post-merge --tool git "$@"'); + expect(advice).toContain('sh -c'); + }); + + it('the guarded line does nothing and exits 0 without teamai', () => { + const line = guardedGitHookLine('post-checkout'); + const r = spawnSync('/bin/sh', ['-c', line, 'post-checkout', '0', '1', '1'], { + encoding: 'utf8', + env: { PATH: '/usr/bin:/bin', HOME: home }, + }); + expect(r.status).toBe(0); + expect(r.stdout + r.stderr).toBe(''); + }); +}); diff --git a/src/__tests__/pull-unreadable-config.test.ts b/src/__tests__/pull-unreadable-config.test.ts index f17c98193..85e07acb0 100644 --- a/src/__tests__/pull-unreadable-config.test.ts +++ b/src/__tests__/pull-unreadable-config.test.ts @@ -187,3 +187,18 @@ describe('pull in a project whose config cannot be read (#784)', () => { expect(log.error).not.toHaveBeenCalled(); }); }); + +describe('a pull a git hook started', () => { + it('does not hand TEAMAI_GIT_HOOK on to what it runs (postPull scripts)', async () => { + const cwd = path.join(tmp, 'plain'); + fs.mkdirSync(cwd); + process.chdir(cwd); + process.env.TEAMAI_GIT_HOOK = 'post-merge'; + try { + await pull({ silent: true }); + expect(process.env.TEAMAI_GIT_HOOK).toBeUndefined(); + } finally { + delete process.env.TEAMAI_GIT_HOOK; + } + }); +}); diff --git a/src/git-hook.ts b/src/git-hook.ts index a354a5e37..d2a7d047e 100644 --- a/src/git-hook.ts +++ b/src/git-hook.ts @@ -14,9 +14,15 @@ * definition followed by its call, which receives them, and the function ends * in `|| :` so the hook always exits 0 (a non-zero `post-checkout` becomes the * exit status of `git worktree add`). + * + * Older Git: without `core.hooksPath`, the same dispatch goes into a + * marker-delimited block in `.git/hooks/`, inserted after the shebang so + * the script's own lines and exit status stay as they were. With + * `core.hooksPath` (a hook manager) nothing is written; `doctor` advises. */ import { ensureTeamaiWrapper, TEAMAI_BIN_DIR } from './builtin-hooks.js'; +import fs from 'node:fs/promises'; import path from 'node:path'; import { getDataHome, type LocalConfig } from './types.js'; import { execCommand } from './utils/exec.js'; @@ -43,13 +49,41 @@ export function gitHookCommand(event: GitHookEvent): string { return `teamai_git_hook() { PATH="$HOME/${TEAMAI_BIN_DIR}:$PATH" teamai hook-dispatch ${event} --tool ${GIT_HOOK_TOOL} "$@" >/dev/null 2>&1 || :; }; teamai_git_hook`; } +/** + * The line a team may commit into its own hook manager's post-checkout and + * post-merge hooks when teamai cannot install its hook: a no-op that exits 0 + * on a machine without teamai. + */ +export function guardedGitHookLine(event: GitHookEvent): string { + return `command -v teamai >/dev/null 2>&1 && teamai hook-dispatch ${event} --tool ${GIT_HOOK_TOOL} "$@" >/dev/null 2>&1 || true`; +} + +// Markers of the block in `.git/hooks/` on Git without config hooks. +const BLOCK_START = '# >>> teamai git hook (managed by teamai) >>>'; +const BLOCK_END = '# <<< teamai git hook <<<'; + +function scriptBlock(event: GitHookEvent): string { + return `${BLOCK_START}\nPATH="$HOME/${TEAMAI_BIN_DIR}:$PATH" teamai hook-dispatch ${event} --tool ${GIT_HOOK_TOOL} "$@" >/dev/null 2>&1 || :\n${BLOCK_END}\n`; +} + +/** `text` with the current block for `event`, or null when it is not a shell script. */ +function withScriptBlock(text: string | null, event: GitHookEvent): string | null { + if (text === null) return `#!/bin/sh\n${scriptBlock(event)}`; + const start = text.indexOf(`${BLOCK_START}\n`); + const end = text.indexOf(`${BLOCK_END}\n`, start); + const rest = start >= 0 && end > start ? text.slice(0, start) + text.slice(end + BLOCK_END.length + 1) : text; + const shebang = /^#![^\n]*(\n|$)/.exec(rest)?.[0] ?? ''; + if (shebang && !/\b(ba|da|k|z)?sh\b/.test(shebang)) return null; + return `${shebang}${shebang && !shebang.endsWith('\n') ? '\n' : ''}${scriptBlock(event)}${rest.slice(shebang.length)}`; +} + export type GitHookInstall = | { installed: true; changed: boolean } - | { installed: false; reason: 'old-git' | 'not-a-repository' }; + | { installed: false; reason: 'hooks-path' | 'other-hook' | 'not-a-repository' }; export type GitHookStatus = | { installed: true } - | { installed: false; reason: 'old-git'; gitVersion: string } + | { installed: false; reason: 'hooks-path' | 'other-hook'; gitVersion: string } | { installed: false; reason: 'not-a-repository' | 'not-configured' }; type Git = (args: string[]) => ReturnType; @@ -70,17 +104,32 @@ async function eventsToWrite(git: Git): Promise { export async function gitHookStatus(repoDir: string): Promise { const git = gitIn(repoDir); const version = (await git(['--version'])).stdout.trim(); - if (!supportsConfigHooks(version)) return { installed: false, reason: 'old-git', gitVersion: version }; if ((await git(['rev-parse', '--git-dir'])).code !== 0) return { installed: false, reason: 'not-a-repository' }; + if (!supportsConfigHooks(version)) { + const scripts = await scriptsToWrite(git, repoDir); + if (scripts.blocked) return { installed: false, reason: scripts.blocked, gitVersion: version }; + return scripts.stale.length === 0 ? { installed: true } : { installed: false, reason: 'not-configured' }; + } return (await eventsToWrite(git)).length === 0 ? { installed: true } : { installed: false, reason: 'not-configured' }; } /** What `doctor` says about a hook that is not installed: the cause, then the next step. */ export function describeMissingGitHook(status: Exclude): string { switch (status.reason) { - case 'old-git': - return `${status.gitVersion || 'This git'} has no config-based hooks (Git 2.54 or later), so new worktrees and ` - + '`git pull` get the team\'s resources only at the next session. Upgrade git, then run `teamai pull`.'; + case 'hooks-path': + case 'other-hook': { + const where = status.reason === 'hooks-path' + ? 'core.hooksPath is set, so teamai leaves the hook manager\'s files alone' + : 'a post-checkout or post-merge hook in .git/hooks is not a shell script, so teamai leaves it alone'; + const owner = status.reason === 'hooks-path' ? 'your hook manager defines' : 'in .git/hooks'; + return `${status.gitVersion || 'This git'} has no config-based hooks (Git 2.54 or later) and ${where}: new ` + + 'worktrees and `git pull` get the team\'s resources only at the next session. Either: ' + + '1. Upgrade Git to 2.54 or later, then run `teamai pull`. ' + + `2. If the team agrees to commit it, run this line from the post-checkout hook ${owner}, ` + + `\`${guardedGitHookLine('post-checkout')}\`, and this one from the post-merge hook, ` + + `\`${guardedGitHookLine('post-merge')}\`; wrap each in \`sh -c '...'\` when the hook config is not a ` + + 'shell script. Both do nothing on a machine without teamai.'; + } case 'not-a-repository': return 'The project root is not a git repository, so there is no git event to hook.'; case 'not-configured': @@ -94,8 +143,9 @@ export function describeMissingGitHook(status: Exclude { const git = gitIn(repoDir); - if (!supportsConfigHooks((await git(['--version'])).stdout)) return { installed: false, reason: 'old-git' }; + const configHooks = supportsConfigHooks((await git(['--version'])).stdout); if ((await git(['rev-parse', '--git-dir'])).code !== 0) return { installed: false, reason: 'not-a-repository' }; + if (!configHooks) return installHookScripts(git, repoDir); // The wrapper is what the hook command finds `teamai` through when the app // that runs git has no login PATH. @@ -111,6 +161,36 @@ export async function installGitHook(repoDir: string): Promise { return { installed: true, changed: stale.length > 0 }; } +type ScriptPlan = { blocked: 'hooks-path' | 'other-hook'; stale: [] } | { blocked: null; stale: { file: string; text: string }[] }; + +async function scriptsToWrite(git: Git, repoDir: string): Promise { + if ((await git(['config', '--get', 'core.hooksPath'])).stdout.trim()) return { blocked: 'hooks-path', stale: [] }; + // The common hooks directory, from a linked worktree too. + const dir = path.resolve(repoDir, (await git(['rev-parse', '--git-path', 'hooks'])).stdout.trim()); + const stale: { file: string; text: string }[] = []; + for (const event of GIT_HOOK_EVENTS) { + const file = path.join(dir, event); + const current = await fs.readFile(file, 'utf8').catch(() => null); + const text = withScriptBlock(current, event); + if (text === null) return { blocked: 'other-hook', stale: [] }; + if (text !== current) stale.push({ file, text }); + } + return { blocked: null, stale }; +} + +async function installHookScripts(git: Git, repoDir: string): Promise { + const plan = await scriptsToWrite(git, repoDir); + if (plan.blocked) return { installed: false, reason: plan.blocked }; + ensureTeamaiWrapper(); + for (const { file, text } of plan.stale) { + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, text); + await fs.chmod(file, (await fs.stat(file)).mode | 0o111); + } + if (plan.stale.length > 0) log.debug(`git hook: installed teamai hook scripts in ${repoDir}`); + return { installed: true, changed: plan.stale.length > 0 }; +} + async function ok(result: ReturnType, key: string): Promise { const { code, stderr } = await result; if (code !== 0) throw new Error(`git config ${key} failed: ${stderr.trim() || `exit ${code}`}`); diff --git a/src/pull.ts b/src/pull.ts index e4d6edd12..2bfcfaef0 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -2221,6 +2221,8 @@ export async function pull( // A pull a git hook started: inline, or the detached one it hands over to. const { isGitHookEvent } = await import('./git-hook.js'); const hookEnv = process.env.TEAMAI_GIT_HOOK; + // Read once; the scripts this pull runs (postPull) must not inherit it. + delete process.env.TEAMAI_GIT_HOOK; if (!options.gitHook && isGitHookEvent(hookEnv)) options = { ...options, gitHook: hookEnv }; // What the scopes below say in their own words, so the post-pull pass does // not repeat it. Owned here rather than at module scope so nothing survives From 9a9b625ce9bd01953bb1bae8aa86fe7552e0b064 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 11:58:25 +0200 Subject: [PATCH 08/14] feat(hooks): uninstall and pull --dry-run cover the git hook --- docs/usage-guide.md | 8 ++ docs/usage-guide.zh-CN.md | 6 ++ skill-data/core/SKILL.md | 2 + skill-data/setup/references/uninstall.md | 4 + .../e2e/git-hook-new-worktree.test.ts | 18 +++++ src/__tests__/git-hook.test.ts | 51 ++++++++++++- src/git-hook.ts | 76 +++++++++++++++++-- src/hooks.ts | 8 +- src/uninstall.ts | 28 +++++++ 9 files changed, 191 insertions(+), 10 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index eb1d48905..16b9b1bce 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -208,6 +208,13 @@ from the post-checkout and post-merge hooks your manager defines (with `post-che `post-merge` as ``), wrapped in `sh -c '...'` when its config is not a shell script. That line does nothing on a machine without teamai. +Once Git is 2.54 or later, the next `teamai pull` installs the config hook and takes the +block out, so the hook does not run twice. `teamai pull --dry-run` says when it would +install or update the hook and writes nothing. `teamai uninstall` in the project removes +the `hook.teamai-post-checkout` and `hook.teamai-post-merge` entries and the marked +blocks; other hooks and script lines stay. A script left with only its shebang is the +one teamai created, and is deleted. + > **Upgrading from an older teamai?** The first `teamai init` / `pull` / `push` / > `contribute` (or `import --from-mr`) after upgrading automatically migrates an existing `/.teamai/` into the partition > (copy → verify → atomic switch), then leaves the old directory as `/.teamai.bak/` @@ -2840,6 +2847,7 @@ What gets removed: - Team-synced rules, including the copies older releases left in `.codex/rules/`, also of rules the team has since removed. Cleanup follows the recorded `toolRoots` location and the publisher's local filenames. A copy there you edited is kept and named in a warning. A removed rule's copy is deleted only if it matches its recorded delivery hash; without that record, it is kept and named too. Codex's `*.rules` files are kept - Team-synced custom agents and CLI built-in agents (your own agents are preserved) - The env block in your shell profile — every candidate file (`.zshrc`, `.bashrc`, `.bash_profile`, `.bash_login`, `.profile`) carrying a block that sources this scope's own `env.sh` is cleaned, not only the one file `pull` would choose today; a block sourcing a different scope's `env.sh` is left alone +- In a project, teamai's git hook: the `hook.teamai-post-checkout` and `hook.teamai-post-merge` entries in the repository's git config, and the marked block in `.git/hooks/post-checkout` and `post-merge` (a script left with only its shebang, the one teamai created, is deleted). Other hooks are kept - The `~/.teamai/` directory ### Uninstall a single tool (`--agent `) diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 15cddcfc9..00696f355 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -191,6 +191,11 @@ post-checkout 与 post-merge hook 中运行 (`` 分别为 `post-checkout` 与 `post-merge`),管理器的配置不是 shell 脚本时用 `sh -c '...'` 包裹。 在没有 teamai 的机器上,这一行什么也不做。 +Git 升级到 2.54 或更高版本后,下一次 `teamai pull` 会安装配置 hook 并移除该代码块,避免 hook 运行两次。 +`teamai pull --dry-run` 会说明是否将安装或更新该 hook,但不写入任何内容。在项目中运行 `teamai uninstall` +会移除 `hook.teamai-post-checkout` 与 `hook.teamai-post-merge` 条目以及带标记的代码块;其他 hook 和脚本行保持不变。 +移除后只剩 shebang 的脚本是 teamai 创建的,会被删除。 + > **从旧版 teamai 升级?** 升级后首次执行 `teamai init` / `pull` / `push` / `contribute` > (或 `import --from-mr`)会自动把已有的 > `/.teamai/` 迁移进分区(复制 → 校验 → 原子切换),并把旧目录保留为 @@ -2640,6 +2645,7 @@ teamai uninstall --agent claude - 团队同步的 rules,包括旧版本留在 `.codex/rules/` 中的副本,团队此后已删除的 rule 的副本也包括在内。清理使用记录的 `toolRoots` 位置和发布者本地的文件名。其中你改过的副本会保留,并在警告中点名。已删除 rule 的副本只有与记录的投递哈希一致时才会删除;没有该记录时也会保留并点名。Codex 的 `*.rules` 文件保留 - 团队同步的自定义 agents 和 CLI 内置 agents(保留用户自建 agents) - Shell profile 中的 env 块——会清理每一个候选文件(`.zshrc`、`.bashrc`、`.bash_profile`、`.bash_login`、`.profile`)中、代码块指向本作用域自身 `env.sh` 的那些,而不仅仅是当前 `pull` 会选中的那一个;指向其他作用域 `env.sh` 的代码块不受影响 +- 项目中 teamai 的 git hook:仓库 git 配置中的 `hook.teamai-post-checkout` 与 `hook.teamai-post-merge` 条目,以及 `.git/hooks/post-checkout` 与 `post-merge` 中带标记的代码块(移除后只剩 shebang 的脚本是 teamai 创建的,会被删除)。其他 hook 保留 - `~/.teamai/` 目录 ### 只卸载单个工具(`--agent `) diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 9a5f23a77..b8e693c93 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -140,6 +140,8 @@ delivers; in single-repo mode it delivers what `git pull` brought, offline. It p exits 0; a failure inside it is recorded, and `teamai doctor` names it (`Last git hook run failed: ...`) with its fix, as does the next interactive `teamai pull`, once. `teamai doctor` also reports whether the hook is installed, and why not. +`pull --dry-run` says when it would install or update the hook, writing nothing; +`teamai uninstall` removes only teamai's hook entries and blocks. A team agent (`agents/.yaml`) can set `model: strong`, `model: fast`, or an alias the team defines, instead of one tool's model. The team maps each alias per diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 0495129fd..3daf392ac 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -65,6 +65,10 @@ and give it your team repo URL."* and neither should you. - If the user only wants to stop auto-sync for one tool but keep TeamAI otherwise, that is the `--agent ` form, not a full uninstall. +- In a project, uninstall also removes teamai's git hook: the + `hook.teamai-post-checkout` / `hook.teamai-post-merge` entries in the repo's git + config and the `# >>> teamai git hook` block in `.git/hooks/post-checkout` and + `post-merge`. Other hooks stay; a script left with only its shebang is deleted. - In a project, uninstall also takes teamai's lines out of `.git/info/exclude` (the `# [teamai:mcp-exclude:start]` block) for MCP configs it proves hold no resolved `${VAR}` value. A line names the path a write lands in: for a config diff --git a/src/__tests__/e2e/git-hook-new-worktree.test.ts b/src/__tests__/e2e/git-hook-new-worktree.test.ts index 635fdf712..9a40abea4 100644 --- a/src/__tests__/e2e/git-hook-new-worktree.test.ts +++ b/src/__tests__/e2e/git-hook-new-worktree.test.ts @@ -587,6 +587,24 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc }); }); + it('pull --dry-run names a missing hook without writing it; uninstall removes only teamai\'s hooks', async () => { + const repo = project('uninstall-project', ['--agent', 'claude']); + await settle(repo); + gitOk(['config', '--local', 'hook.mine.command', 'echo mine'], repo); + gitOk(['config', '--local', 'hook.mine.event', 'post-checkout'], repo); + gitOk(['config', '--local', '--remove-section', 'hook.teamai-post-checkout'], repo); + const before = gitOk(['config', '--local', '--list'], repo); + + const dry = teamai(['pull', '--dry-run'], repo); + expect(dry.output).toContain('Would install or update the teamai git hook'); + expect(gitOk(['config', '--local', '--list'], repo)).toBe(before); + + const r = teamai(['uninstall', '--force'], repo); + expect(r.code, r.output).toBe(0); + expect(gitOk(['config', '--get-regexp', '^hook\\.'], repo).split('\n')) + .toEqual(['hook.mine.command echo mine', 'hook.mine.event post-checkout']); + }); + it('with no enabledAgents, creates the tool roots the main checkout has, and only those', () => { const codexProject = project('codex-project', []); fs.mkdirSync(path.join(codexProject, '.codex')); diff --git a/src/__tests__/git-hook.test.ts b/src/__tests__/git-hook.test.ts index 490d25f66..8291597ae 100644 --- a/src/__tests__/git-hook.test.ts +++ b/src/__tests__/git-hook.test.ts @@ -4,7 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { describeMissingGitHook, gitHookStatus, guardedGitHookLine, installGitHook } from '../git-hook.js'; +import { describeMissingGitHook, gitHookStatus, guardedGitHookLine, installGitHook, removeGitHook } from '../git-hook.js'; const gitVersion = (): [number, number] => { const m = /(\d+)\.(\d+)/.exec(execFileSync('git', ['--version'], { encoding: 'utf8' })); @@ -67,6 +67,22 @@ describe.skipIf(!configHooks)('teamai git hook in the repository config', () => expect(git(['hook', 'list', 'post-checkout'], path.join(sandbox, 'wt')).stdout.trim()).toBe('teamai-post-checkout'); }); + it('removal drops only teamai\'s hook entries; a dry run reports them and writes nothing', async () => { + git(['config', '--local', 'hook.mine.command', 'echo mine']); + git(['config', '--local', 'hook.mine.event', 'post-checkout']); + expect(await installGitHook(repo, { dryRun: true })).toEqual({ installed: true, changed: true }); + expect(git(['config', '--get-regexp', '^hook\\.teamai']).stdout).toBe(''); + await installGitHook(repo); + + const planned = await removeGitHook(repo, { dryRun: true }); + expect(planned).toEqual(['hook.teamai-post-checkout', 'hook.teamai-post-merge']); + expect(git(['config', '--get-regexp', '^hook\\.teamai']).stdout).not.toBe(''); + + expect(await removeGitHook(repo)).toEqual(planned); + expect(git(['config', '--get-regexp', '^hook\\.']).stdout.trim().split('\n')) + .toEqual(['hook.mine.command echo mine', 'hook.mine.event post-checkout']); + }); + it('is idempotent', async () => { await installGitHook(repo); await installGitHook(repo); @@ -215,6 +231,39 @@ describe('teamai hook script on a Git without config hooks', () => { expect(advice).toContain('sh -c'); }); + it('a dry run reports the change without writing the hook', async () => { + expect(await installGitHook(repo, { dryRun: true })).toEqual({ installed: true, changed: true }); + expect(fs.existsSync(hookFile('post-checkout'))).toBe(false); + expect(fs.existsSync(hookFile('post-merge'))).toBe(false); + }); + + it('removal takes out only the block, and the script teamai created', async () => { + const original = '#!/bin/sh\necho mine >> "$0.log"\n'; + fs.writeFileSync(hookFile('post-checkout'), original, { mode: 0o755 }); + await installGitHook(repo); + + const planned = await removeGitHook(repo, { dryRun: true }); + expect(planned).toHaveLength(2); + expect(fs.readFileSync(hookFile('post-checkout'), 'utf8')).not.toBe(original); + + expect(await removeGitHook(repo)).toEqual(planned); + expect(fs.readFileSync(hookFile('post-checkout'), 'utf8')).toBe(original); + expect(fs.existsSync(hookFile('post-merge'))).toBe(false); + expect(await removeGitHook(repo)).toEqual([]); + }); + + it.skipIf(!configHooks)('after a Git upgrade, the config hook replaces the block (no double dispatch)', async () => { + const original = '#!/bin/sh\necho mine >> "$0.log"\n'; + fs.writeFileSync(hookFile('post-checkout'), original, { mode: 0o755 }); + await installGitHook(repo); + process.env.PATH = saved.PATH; + + expect(await installGitHook(repo)).toEqual({ installed: true, changed: true }); + expect(fs.readFileSync(hookFile('post-checkout'), 'utf8')).toBe(original); + expect(fs.existsSync(hookFile('post-merge'))).toBe(false); + expect(run(['hook', 'list', 'post-checkout']).stdout).toContain('teamai-post-checkout'); + }); + it('the guarded line does nothing and exits 0 without teamai', () => { const line = guardedGitHookLine('post-checkout'); const r = spawnSync('/bin/sh', ['-c', line, 'post-checkout', '0', '1', '1'], { diff --git a/src/git-hook.ts b/src/git-hook.ts index d2a7d047e..eb419a353 100644 --- a/src/git-hook.ts +++ b/src/git-hook.ts @@ -66,12 +66,17 @@ function scriptBlock(event: GitHookEvent): string { return `${BLOCK_START}\nPATH="$HOME/${TEAMAI_BIN_DIR}:$PATH" teamai hook-dispatch ${event} --tool ${GIT_HOOK_TOOL} "$@" >/dev/null 2>&1 || :\n${BLOCK_END}\n`; } +/** `text` without teamai's block. */ +function withoutScriptBlock(text: string): string { + const start = text.indexOf(`${BLOCK_START}\n`); + const end = text.indexOf(`${BLOCK_END}\n`, start); + return start >= 0 && end > start ? text.slice(0, start) + text.slice(end + BLOCK_END.length + 1) : text; +} + /** `text` with the current block for `event`, or null when it is not a shell script. */ function withScriptBlock(text: string | null, event: GitHookEvent): string | null { if (text === null) return `#!/bin/sh\n${scriptBlock(event)}`; - const start = text.indexOf(`${BLOCK_START}\n`); - const end = text.indexOf(`${BLOCK_END}\n`, start); - const rest = start >= 0 && end > start ? text.slice(0, start) + text.slice(end + BLOCK_END.length + 1) : text; + const rest = withoutScriptBlock(text); const shebang = /^#![^\n]*(\n|$)/.exec(rest)?.[0] ?? ''; if (shebang && !/\b(ba|da|k|z)?sh\b/.test(shebang)) return null; return `${shebang}${shebang && !shebang.endsWith('\n') ? '\n' : ''}${scriptBlock(event)}${rest.slice(shebang.length)}`; @@ -141,17 +146,21 @@ export function describeMissingGitHook(status: Exclude { +export async function installGitHook(repoDir: string, opts: { dryRun?: boolean } = {}): Promise { const git = gitIn(repoDir); const configHooks = supportsConfigHooks((await git(['--version'])).stdout); if ((await git(['rev-parse', '--git-dir'])).code !== 0) return { installed: false, reason: 'not-a-repository' }; - if (!configHooks) return installHookScripts(git, repoDir); + if (!configHooks) return installHookScripts(git, repoDir, opts); + + const stale = await eventsToWrite(git); + if (opts.dryRun) return { installed: true, changed: stale.length > 0 }; + // A block an older Git needed would dispatch a second time beside the config hook. + await removeHookScriptBlocks(git, repoDir); // The wrapper is what the hook command finds `teamai` through when the app // that runs git has no login PATH. ensureTeamaiWrapper(); - const stale = await eventsToWrite(git); for (const event of stale) { const key = `hook.${hookName(event)}`; await ok(git(['config', '--local', `${key}.command`, gitHookCommand(event)]), key); @@ -178,9 +187,10 @@ async function scriptsToWrite(git: Git, repoDir: string): Promise { return { blocked: null, stale }; } -async function installHookScripts(git: Git, repoDir: string): Promise { +async function installHookScripts(git: Git, repoDir: string, opts: { dryRun?: boolean }): Promise { const plan = await scriptsToWrite(git, repoDir); if (plan.blocked) return { installed: false, reason: plan.blocked }; + if (opts.dryRun) return { installed: true, changed: plan.stale.length > 0 }; ensureTeamaiWrapper(); for (const { file, text } of plan.stale) { await fs.mkdir(path.dirname(file), { recursive: true }); @@ -191,6 +201,58 @@ async function installHookScripts(git: Git, repoDir: string): Promise 0 }; } +/** + * Take teamai's block out of the post-checkout and post-merge scripts in the + * repository's own hooks directory (and in core.hooksPath's, should a block + * predate it). A script left with only the shebang is the one teamai created + * when there was none, so it goes too. Returns the files changed. + */ +async function removeHookScriptBlocks(git: Git, repoDir: string, opts: { dryRun?: boolean } = {}): Promise { + const dirs = new Set(); + for (const args of [['rev-parse', '--git-common-dir'], ['rev-parse', '--git-path', 'hooks']]) { + const { code, stdout } = await git(args); + if (code !== 0) continue; + const out = path.resolve(repoDir, stdout.trim()); + dirs.add(args.length === 2 ? path.join(out, 'hooks') : out); + } + const changed: string[] = []; + for (const dir of dirs) { + for (const event of GIT_HOOK_EVENTS) { + const file = path.join(dir, event); + const current = await fs.readFile(file, 'utf8').catch(() => null); + if (current === null) continue; + const text = withoutScriptBlock(current); + if (text === current) continue; + changed.push(file); + if (opts.dryRun) continue; + if (/^(#![^\n]*\n?)?$/.test(text)) await fs.rm(file); + else await fs.writeFile(file, text); + } + } + return changed; +} + +/** + * Remove everything installGitHook wrote in the repository holding `repoDir`: + * the `hook.teamai-` config sections and the blocks in hook scripts. + * Other hooks and lines stay. Returns what was (or, on a dry run, would be) + * removed: config section names, then script paths. + */ +export async function removeGitHook(repoDir: string, opts: { dryRun?: boolean } = {}): Promise { + const git = gitIn(repoDir); + if ((await git(['rev-parse', '--git-dir'])).code !== 0) return []; + const removed: string[] = []; + for (const event of GIT_HOOK_EVENTS) { + const section = `hook.${hookName(event)}`; + const present = (await git(['config', '--local', '--get-regexp', `^${section.replace(/\./g, '\\.')}\\.`])).stdout.trim(); + if (!present) continue; + removed.push(section); + if (!opts.dryRun) await ok(git(['config', '--local', '--remove-section', section]), section); + } + removed.push(...await removeHookScriptBlocks(git, repoDir, opts)); + return removed; +} + async function ok(result: ReturnType, key: string): Promise { const { code, stderr } = await result; if (code !== 0) throw new Error(`git config ${key} failed: ${stderr.trim() || `exit ${code}`}`); diff --git a/src/hooks.ts b/src/hooks.ts index 07f640fa4..e4d5e32a1 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1868,6 +1868,7 @@ export async function reconcileTeamHooksForConfig( // pass would actually reach, which is what hookToolPaths decides below too. const hookToolPaths = scopedToolPaths(teamConfig, { ...localConfig, scope: hookScope }); if (opts.dryRun) { + if (!opts.removeAll) await installProjectGitHook(localConfig, { dryRun: true }); if (!opts.removeAll && 'pi' in hookToolPaths && (!filterAgents || filterAgents.includes('pi'))) { await reportPiSkippedTeamHooks(teamDefs, baseDir, localConfig.scope === 'project' ? (localConfig.projectRoot ?? baseDir) : undefined, builtin); } @@ -1924,11 +1925,14 @@ export async function reconcileTeamHooksForConfig( * User scope installs none: its resources live in HOME, which a new worktree * does not change. A failure is reported and does not stop the caller. */ -async function installProjectGitHook(localConfig: LocalConfig): Promise { +async function installProjectGitHook(localConfig: LocalConfig, opts: { dryRun?: boolean } = {}): Promise { if (localConfig.scope !== 'project' || !localConfig.projectRoot) return; const { installGitHook } = await import('./git-hook.js'); try { - const result = await installGitHook(localConfig.projectRoot); + const result = await installGitHook(localConfig.projectRoot, opts); + if (opts.dryRun && result.installed && result.changed) { + log.info(`Would install or update the teamai git hook (post-checkout, post-merge) in ${localConfig.projectRoot}`); + } if (!result.installed) log.debug(`git hook: not installed in ${localConfig.projectRoot} (${result.reason})`); } catch (e) { log.warn(`Could not install the teamai git hook in ${localConfig.projectRoot}: ${(e as Error).message}. ` diff --git a/src/uninstall.ts b/src/uninstall.ts index 301fb70ac..4c7c47171 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -119,6 +119,8 @@ interface RemovalPlan { docsDir: string | null; /** The .git/info/exclude files holding teamai's MCP config block (#882), each with its patterns and the paths each protects. */ gitExcludes: Map>; + /** teamai's git hook in the project repository: config sections and hook scripts holding its block. */ + gitHook: { repoDir: string; entries: string[] } | null; /** The .teamai home directory path. */ teamaiHome: string; /** Whether teamaiHome exists on disk. */ @@ -662,6 +664,7 @@ async function buildRemovalPlan( shellProfiles: [], docsDir: null, gitExcludes: new Map(), + gitHook: null, teamaiHome, teamaiHomeExists: includeShared && await pathExists(teamaiHome), unpublishedQueues: includeShared ? await listQueuesIn(teamaiHome) : [], @@ -780,6 +783,12 @@ async function buildRemovalPlan( for (const file of Object.keys((await readResolvedMcpFiles(cfg)).files)) dirs.push(path.dirname(file)); } plan.gitExcludes = await findMcpGitExcludes(dirs); + // (h) teamai's git hook, in the config every worktree shares. + if (localConfig.projectRoot) { + const { removeGitHook } = await import('./git-hook.js'); + const entries = await removeGitHook(localConfig.projectRoot, { dryRun: true }).catch(() => []); + if (entries.length > 0) plan.gitHook = { repoDir: localConfig.projectRoot, entries }; + } } } @@ -804,6 +813,7 @@ function isPlanEmpty(plan: RemovalPlan): boolean { plan.shellProfiles.length === 0 && plan.docsDir === null && plan.gitExcludes.size === 0 && + plan.gitHook === null && !plan.teamaiHomeExists ); } @@ -924,6 +934,12 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { console.log(''); } + if (plan.gitHook) { + console.log(` Git hook in ${plan.gitHook.repoDir} (teamai's entries and blocks):`); + for (const entry of plan.gitHook.entries) console.log(` ${entry}`); + console.log(''); + } + if (plan.teamaiHomeExists) { console.log(' TeamAI home directory:'); console.log(` ${plan.teamaiHome}/`); @@ -957,6 +973,18 @@ async function teardownPlugins(): Promise { } async function executeRemoval(plan: RemovalPlan): Promise { + if (plan.gitHook) { + const { removeGitHook } = await import('./git-hook.js'); + try { + await removeGitHook(plan.gitHook.repoDir); + log.info(`Removed the teamai git hook from ${plan.gitHook.repoDir}`); + } catch (e) { + log.warn(`Could not remove the teamai git hook from ${plan.gitHook.repoDir}: ${(e as Error).message}. ` + + 'Remove it yourself: `git config --local --remove-section hook.teamai-post-checkout` (and hook.teamai-post-merge), ' + + 'and the `# >>> teamai git hook` block in .git/hooks/post-checkout and post-merge.'); + } + } + // (a) Remove hooks from tool settings (built-in A + team B via the manifest). // Each settings entry carries the manifest for its own location (HOME/user // or a legacy /project copy), so team hooks are stripped at the From 8c1a667309d99ce14c377a84206d68887ca5bb53 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 12:01:37 +0200 Subject: [PATCH 09/14] feat(init): ask which AI tools to set up in interactive project-scope init Project-scope init in a terminal without --agent reuses the single-repo tool picker (Auto = tools installed under HOME). The choice is added to enabledAgents before the tool roots are created, so only the chosen tools' roots appear and the closing pull fills them. --agent and non-interactive runs skip the picker; the latter still create no root. --- docs/usage-guide.md | 16 +++-- docs/usage-guide.zh-CN.md | 10 +-- skill-data/core/references/troubleshooting.md | 5 +- skill-data/setup/SKILL.md | 7 +- skill-data/setup/references/join-member.md | 3 +- skill-data/setup/references/setup-admin.md | 3 +- src/__tests__/init.test.ts | 64 +++++++++++++++++++ src/init.ts | 18 +++++- 8 files changed, 107 insertions(+), 19 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index eb1d48905..d847ee8c3 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -162,11 +162,15 @@ there stops if it finds a team rule or skill that differs from the team repo, si cannot tell a teammate's update from your edit. `teamai pull` replaces those files, so copy any you edited somewhere safe, pull, put your edits back and push again. Per-agent project roots (`.claude/`, `.cursor/`, `.codebuddy/`, …) are still created inside the -workspace. `teamai init --agent ` creates the root of each tool it names, then -ends with a pull that fills it. Otherwise **SessionStart** creates the root of the tool -that just opened: opening Claude Code creates `.claude/`, then pull writes into it. A -bare `teamai pull`, and an `init` without `--agent`, still skip tools whose project -root does not exist, so they never invent agent directories for tools you have not +workspace. `teamai init` creates the root of each tool you choose, then ends with a +pull that fills it: name the tools with `--agent `, or, in a terminal without +`--agent`, pick them from the same picker single-repo mode uses (option 1, **Auto**, +is the tools installed under your home dir and the Enter default). The choice is +added to `enabledAgents`, so a re-run adds tools without dropping earlier ones. +Otherwise **SessionStart** creates the root of the tool that just opened: opening +Claude Code creates `.claude/`, then pull writes into it. A bare `teamai pull`, and a +non-interactive `init` without `--agent`, still skip tools whose project root does +not exist, so they never invent agent directories for tools you have not chosen or opened in this project. A new worktree does not wait for that first session. In project scope, `teamai init` @@ -2878,7 +2882,7 @@ teamai init --repo https://github.com/yourorg/yourrepo --force **Q: After `teamai init` in a project, there is no `.claude/` (or `.cursor/`, `.codebuddy/`) directory?** -That is expected for a built-in tool when `init` ran without `--agent`: it does not know which agent you will open. Run `teamai init --agent claude` (or `cursor`, `codebuddy`, …) to create that tool's root and fill it before init exits, or open the tool in the project: the SessionStart hook creates that tool's project root and then pulls. A bare `teamai pull` will not create missing agent roots. The exception is a custom agent defined only in `teamai.yaml`'s `toolPaths` (not one of the built-in tools) — `init --agent ` creates that agent's root itself, since nothing else ever would. This only works for git-backed init (default or `--self`): an HTTP init (`--http`) never clones a local `teamai.yaml`, so it has no custom paths to seed from and only ever creates roots for built-in tools that are already installed. +That is expected for a built-in tool when `init` ran without `--agent` and without a terminal (no picker): it does not know which agent you will open. Run `teamai init --agent claude` (or `cursor`, `codebuddy`, …) to create that tool's root and fill it before init exits, or open the tool in the project: the SessionStart hook creates that tool's project root and then pulls. A bare `teamai pull` will not create missing agent roots. The exception is a custom agent defined only in `teamai.yaml`'s `toolPaths` (not one of the built-in tools) — `init --agent ` creates that agent's root itself, since nothing else ever would. This only works for git-backed init (default or `--self`): an HTTP init (`--http`) never clones a local `teamai.yaml`, so it has no custom paths to seed from and only ever creates roots for built-in tools that are already installed. **Q: Hooks aren't firing automatically?** diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 15cddcfc9..d673d72bd 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -157,9 +157,11 @@ teamai init https://github.com/yourorg/yourrepo `teamai pull` 会向它完整同步一次,即使团队仓库自另一个检出 pull 之后并未变化。worktree 尚未 pull 过时, 若 `teamai push` 发现与团队仓库不同的团队 rule 或 skill 就会停止,因为无法区分队友的更新和你的修改。 `teamai pull` 会覆盖这些文件:如有修改,请先另存一份,再在该 worktree 中执行 `teamai pull`,放回修改后重新 push。各 Agent 的项目根目录 -(`.claude/`、`.cursor/`、`.codebuddy/` 等)仍在工作区内创建。`teamai init --agent ` -会为其指定的每个工具创建根目录,并在结束时执行一次 pull 将其填充。否则由 **SessionStart** 按刚打开的 -工具创建:打开 Claude Code 时会创建 `.claude/`,再由 pull 写入。单独执行 `teamai pull`,以及不带 +(`.claude/`、`.cursor/`、`.codebuddy/` 等)仍在工作区内创建。`teamai init` 会为你选择的每个工具 +创建根目录,并在结束时执行一次 pull 将其填充:用 `--agent ` 指定工具;或在终端中不带 `--agent` +运行时,从与单仓库模式相同的选择器中勾选(第 1 项 **Auto** 为你 home 目录下已安装的工具,也是回车默认项)。 +所选工具会追加到 `enabledAgents`,因此重新执行只会新增工具,不会丢掉之前的选择。否则由 **SessionStart** 按刚打开的 +工具创建:打开 Claude Code 时会创建 `.claude/`,再由 pull 写入。单独执行 `teamai pull`,以及非交互且不带 `--agent` 的 `init`,仍会跳过项目里还不存在根目录的工具,因此不会给尚未在本项目选择或打开过的 Agent 凭空建目录。 新 worktree 不必等到第一次会话。在项目 scope 下,`teamai init` 与 `teamai pull` 会在仓库的本地 @@ -2678,7 +2680,7 @@ teamai init --repo https://github.com/yourorg/yourrepo --force **Q: 在项目里执行 `teamai init` 后没有 `.claude/`(或 `.cursor/`、`.codebuddy/`)目录?** -对内置工具而言,`init` 未带 `--agent` 时这是预期行为:它不知道你会打开哪个 Agent。执行 `teamai init --agent claude`(或 `cursor`、`codebuddy` 等)会在 init 结束前创建该工具的根目录并填充;或者在项目中打开该工具:SessionStart hook 会创建该工具的项目根目录并随后 pull。单独执行 `teamai pull` 不会为缺失的 Agent 根目录建目录。例外是仅在 `teamai.yaml` 的 `toolPaths` 中定义的自定义 Agent(不属于内置工具)——`init --agent ` 会自行创建该 Agent 的根目录,因为没有其他流程会为它创建。这仅在 git 模式的 init(默认或 `--self`)下生效:HTTP init(`--http`)不会在本地克隆 `teamai.yaml`,因此没有自定义路径可供创建,只会为已安装的内置工具创建根目录。 +对内置工具而言,`init` 未带 `--agent` 且没有终端(不弹选择器)时这是预期行为:它不知道你会打开哪个 Agent。执行 `teamai init --agent claude`(或 `cursor`、`codebuddy` 等)会在 init 结束前创建该工具的根目录并填充;或者在项目中打开该工具:SessionStart hook 会创建该工具的项目根目录并随后 pull。单独执行 `teamai pull` 不会为缺失的 Agent 根目录建目录。例外是仅在 `teamai.yaml` 的 `toolPaths` 中定义的自定义 Agent(不属于内置工具)——`init --agent ` 会自行创建该 Agent 的根目录,因为没有其他流程会为它创建。这仅在 git 模式的 init(默认或 `--self`)下生效:HTTP init(`--http`)不会在本地克隆 `teamai.yaml`,因此没有自定义路径可供创建,只会为已安装的内置工具创建根目录。 **Q: Hooks 没有自动触发?** diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 759f3a22c..56cba2835 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -17,8 +17,9 @@ reports before anything else. This is the #1 onboarding issue. In order: 1. **Did init pick this tool?** `teamai init` ends with a pull, but a project-scope - init creates only the directories of tools named with `--agent`. Without it, a - tool's project directory appears when that tool opens a session there. Re-run + init creates only the directories of tools named with `--agent` or picked in + its interactive tool picker. Run without a terminal and without `--agent`, it + creates none, and a tool's project directory appears when that tool opens a session there. Re-run `teamai init --agent ` to add the tool and fill it now. 2. **Sync manually to confirm:** ```bash diff --git a/skill-data/setup/SKILL.md b/skill-data/setup/SKILL.md index e0146efc4..3ea247d9f 100644 --- a/skill-data/setup/SKILL.md +++ b/skill-data/setup/SKILL.md @@ -50,9 +50,10 @@ and create-repo URLs, and the per-provider caveats, and points at trust-gate, CodeBuddy design). Verify the real per-tool result with `teamai doctor` and `teamai hooks list`. 3. **`teamai init` ends with a pull.** In user scope, and in project scope for each - tool named with `--agent`, the team's skills, rules and MCP servers are in place - when init exits; there is no need to run `teamai pull` after it. A project-scope - init without `--agent` creates no tool directory: a tool's directory appears and + tool named with `--agent` (or picked in init's tool picker when a person runs it + in a terminal), the team's skills, rules and MCP servers are in place when init + exits; there is no need to run `teamai pull` after it. Run from an agent shell + (no terminal), a project-scope init without `--agent` creates no tool directory: a tool's directory appears and fills when the user opens that tool in the project. Init also injects a session-start hook that keeps resources synced from then on. 4. **Finish with `teamai doctor`.** Every setup or onboarding flow ends by running diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index fdb91d336..e766a313d 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -128,7 +128,8 @@ section "Which tools actually get hooks". `teamai init` ends with a pull, so the team's skills, rules and MCP servers are already in place in user scope, and in project scope for each tool named with -`--agent`. A project-scope init without `--agent` creates no tool directory; a +`--agent` or picked in init's interactive tool picker. A project-scope init run +without a terminal and without `--agent` creates no tool directory; a tool's directory fills when the user first opens that tool in the project. To confirm: diff --git a/skill-data/setup/references/setup-admin.md b/skill-data/setup/references/setup-admin.md index 28e7bea03..e947b110b 100644 --- a/skill-data/setup/references/setup-admin.md +++ b/skill-data/setup/references/setup-admin.md @@ -268,7 +268,8 @@ carries counts + tool names only, on a separate branch of that same repo.) Tell them to send the URL + this line to each member. 3. Remind them (in their language): a member's `teamai init` ends with a pull, so the team's resources are in place when it exits (in project scope, for each - tool named with `--agent`; without it, a tool's directory fills when the + tool named with `--agent` or picked in init's tool picker; otherwise a tool's + directory fills when the member first opens that tool in the project). Resources the team adds later arrive at the next session start. diff --git a/src/__tests__/init.test.ts b/src/__tests__/init.test.ts index d9f8ad5a5..0289378e1 100644 --- a/src/__tests__/init.test.ts +++ b/src/__tests__/init.test.ts @@ -232,6 +232,7 @@ vi.mock('../utils/prompt.js', async (importOriginal) => ({ } return Promise.resolve(defaultValue ?? false); }), + askSelection: vi.fn(), closePrompt: vi.fn(), })); @@ -867,6 +868,69 @@ describe('init', () => { ); }); + describe('project-scope tool picker', () => { + let originalIsTTY: boolean | undefined; + let logSpy: ReturnType; + + beforeEach(() => { + originalIsTTY = process.stdin.isTTY; + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const projectLocalPath = path.join(process.cwd(), '.teamai', 'team-repo'); + let cloneDone = false; + pathExistsFn = (p: string) => { + if (p === projectLocalPath) return cloneDone; + if (p.endsWith(`${path.sep}.git`) || p.endsWith('/.git')) return true; + return false; + }; + mockGfRepoClone.mockImplementation(() => { cloneDone = true; }); + questionAnswers = ['n', '1']; + }); + + afterEach(() => { + logSpy.mockRestore(); + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); + }); + + it('asks which tools when interactive without --agent and saves the choice', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + const { askSelection } = await import('../utils/prompt.js'); + vi.mocked(askSelection).mockResolvedValueOnce([1]); // option 2 = Claude Code + const { saveLocalConfigForScope } = await import('../config.js'); + + await init({ repo: 'https://git.woa.com/HyperAI/teamai-test.git' }); + + expect(askSelection).toHaveBeenCalledTimes(1); + const printed = logSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(printed).toContain('Which AI tools do you use in this project?'); + expect(saveLocalConfigForScope).toHaveBeenCalledWith( + expect.objectContaining({ scope: 'project', enabledAgents: ['claude'] }), + 'project', + process.cwd(), + ); + }); + + it('skips the picker when --agent is given', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + const { askSelection } = await import('../utils/prompt.js'); + + await init({ repo: 'https://git.woa.com/HyperAI/teamai-test.git', agent: ['codex'] }); + + expect(askSelection).not.toHaveBeenCalled(); + }); + + it('skips the picker and enables nothing when not interactive', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + const { askSelection } = await import('../utils/prompt.js'); + const { saveLocalConfigForScope } = await import('../config.js'); + + await init({ repo: 'https://git.woa.com/HyperAI/teamai-test.git' }); + + expect(askSelection).not.toHaveBeenCalled(); + const saved = vi.mocked(saveLocalConfigForScope).mock.calls[0]?.[0]; + expect(saved?.enabledAgents).toBeUndefined(); + }); + }); + it('should print scope summary without interactive Select scope when --scope is provided', async () => { let cloneDone = false; pathExistsFn = (p: string) => { diff --git a/src/init.ts b/src/init.ts index 5794cff5e..3fdc6b82c 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1012,6 +1012,8 @@ export async function promptForSelfModeAgents(options: { agent?: string | string[]; silent?: boolean; force?: boolean; + /** Project-scope init: asks which tools the member uses here, commits nothing. */ + projectScope?: boolean; }): Promise { const explicit = normalizeAgentList(options.agent); if (explicit.length > 0) return explicit; @@ -1038,8 +1040,13 @@ export async function promptForSelfModeAgents(options: { : 'Auto — none detected (will set up Claude Code)'; console.log(''); - console.log('Which AI tools should teamai set up in this repo?'); - console.log('(creates the skills dir, injects hooks, commits settings to main)'); + if (options.projectScope) { + console.log('Which AI tools do you use in this project?'); + console.log('(creates their tool dirs here and syncs the team\'s resources into them)'); + } else { + console.log('Which AI tools should teamai set up in this repo?'); + console.log('(creates the skills dir, injects hooks, commits settings to main)'); + } console.log(''); console.log(` 1. ${autoLabel}`); tools.forEach((t, i) => { @@ -2003,6 +2010,13 @@ export async function init(options: GlobalOptions & { // Persist --agent into enabledAgents (additive across runs) const requestedAgents = normalizeAgentList(options.agent); + // Interactive project init without --agent asks which tools the member uses. + // Non-interactive runs skip this on purpose: the picker's own non-TTY branch + // mirrors HOME tools, while project init then creates no root. + if (scope === 'project' && requestedAgents.length === 0 + && !options.silent && !options.force && isInteractive()) { + requestedAgents.push(...await promptForSelfModeAgents({ projectScope: true })); + } if (requestedAgents.length > 0) { // As loaded before the clone: that config may have been moved aside since. const prev = carriedConfig?.enabledAgents ?? []; From 4d2c5f0f1d29d380004bec5bb1760ae4eac0f0aa Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 13:41:55 +0200 Subject: [PATCH 10/14] test: verify agents and team hooks before session startup --- .../e2e/git-hook-new-worktree.test.ts | 28 +++++++++++++++---- src/__tests__/e2e/init-ends-with-pull.test.ts | 11 +++++++- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/__tests__/e2e/git-hook-new-worktree.test.ts b/src/__tests__/e2e/git-hook-new-worktree.test.ts index 9a40abea4..2f0a3347d 100644 --- a/src/__tests__/e2e/git-hook-new-worktree.test.ts +++ b/src/__tests__/e2e/git-hook-new-worktree.test.ts @@ -86,11 +86,15 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc const delivered = (dir: string) => ({ skill: fs.existsSync(path.join(dir, '.claude', 'skills', 'team-skill', 'SKILL.md')), + agent: fs.existsSync(path.join(dir, '.claude', 'agents', 'team-agent.md')), + hook: fs.existsSync(path.join(home, '.claude', 'settings.json')) + && fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8').includes(`echo team-hook-v1`) + && fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8').includes(dir), rule: fs.existsSync(path.join(dir, '.claude', 'rules', 'team-rule.md')), mcp: fs.existsSync(path.join(dir, '.mcp.json')) && fs.readFileSync(path.join(dir, '.mcp.json'), 'utf8').includes('team-api'), }); - const ALL = { skill: true, rule: true, mcp: true }; - const NOTHING = { skill: false, rule: false, mcp: false }; + const ALL = { skill: true, agent: true, hook: true, rule: true, mcp: true }; + const NOTHING = { skill: false, agent: false, hook: false, rule: false, mcp: false }; /** The project partition (data home) `init` created for the repo at `root`. */ const partitionOf = (root: string): string => { @@ -119,9 +123,11 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc fs.mkdirSync(path.dirname(path.join(seed, rel)), { recursive: true }); fs.writeFileSync(path.join(seed, rel), content); }; - write('teamai.yaml', `team: git-hook-e2e\nrepo: ${FAKE_URL}\nprovider: git\nreviewers: []\nsharing:\n mcp:\n autoApply: true\n`); + write('teamai.yaml', `team: git-hook-e2e\nrepo: ${FAKE_URL}\nprovider: git\nreviewers: []\nsharing:\n mcp:\n autoApply: true\n hooks:\n autoApply: true\n requireTeamScripts: false\n`); write('skills/team-skill/SKILL.md', '---\nname: team-skill\ndescription: Team skill fixture\n---\n\n# Team skill\n'); write('rules/team-rule.md', '# Team rule\n'); + write('agents/team-agent.yaml', 'name: team-agent\ndescription: Startup agent fixture\ninstructions: Team agent v1\n'); + write('hooks/hooks.yaml', 'hooks:\n - id: startup-guard\n description: Startup hook fixture\n event: SessionStart\n command: echo team-hook-v1\n'); write('mcp/mcp.yaml', 'servers:\n - name: team-api\n transport: http\n url: https://team.example.com/mcp\n'); gitOk(['init', '-q', '-b', 'main'], seed); gitOk(['add', '-A'], seed); @@ -142,7 +148,7 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc expect(gitOk(['hook', 'list', 'post-merge'], claudeProject)).toBe('teamai-post-merge'); }); - it('delivers skills, rules and MCP for enabledAgents before git worktree add returns, silently', () => { + it('delivers skills, agents, rules, MCP and team hooks for enabledAgents before git worktree add returns, silently', () => { const wt = worktreeAdd(claudeProject, 'wt-claude'); expect(wt.code).toBe(0); @@ -406,11 +412,19 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc }; }; - it('separate team repo: a skill the team published is delivered before git pull returns', async () => { + it('separate team repo: published resources are delivered before git pull returns', async () => { const repo = project('merge-project', ['--agent', 'claude']); const businessChange = withOrigin(repo); await settle(repo); pushSkill('merged-skill'); + const teamChange = path.join(sandbox, 'push-merged-skill'); + fs.writeFileSync(path.join(teamChange, 'agents', 'team-agent.yaml'), 'name: team-agent\ndescription: Startup agent fixture\ninstructions: Team agent v2\n'); + fs.writeFileSync(path.join(teamChange, 'rules', 'team-rule.md'), '# Team rule v2\n'); + fs.writeFileSync(path.join(teamChange, 'mcp', 'mcp.yaml'), 'servers:\n - name: team-api\n transport: http\n url: https://team-v2.example.com/mcp\n'); + fs.writeFileSync(path.join(teamChange, 'hooks', 'hooks.yaml'), 'hooks:\n - id: startup-guard\n description: Startup hook fixture\n event: SessionStart\n command: echo team-hook-v2\n'); + gitOk(['add', '-A'], teamChange); + gitOk(['commit', '-q', '-m', 'update all startup resources'], teamChange); + gitOk(['push', '-q', 'origin', 'HEAD:main'], teamChange); businessChange(); const r = git(['pull', '-q'], repo); @@ -419,6 +433,10 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc expect(r.output).toBe(''); expect(fs.existsSync(path.join(repo, 'change-1.txt'))).toBe(true); expect(hasSkill(repo, 'merged-skill')).toBe(true); + expect(fs.readFileSync(path.join(repo, '.claude', 'agents', 'team-agent.md'), 'utf8')).toContain('Team agent v2'); + expect(fs.readFileSync(path.join(repo, '.claude', 'rules', 'team-rule.md'), 'utf8')).toContain('Team rule v2'); + expect(fs.readFileSync(path.join(repo, '.mcp.json'), 'utf8')).toContain('https://team-v2.example.com/mcp'); + expect(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8')).toContain('echo team-hook-v2'); await settle(repo); }); diff --git a/src/__tests__/e2e/init-ends-with-pull.test.ts b/src/__tests__/e2e/init-ends-with-pull.test.ts index 6032b34ee..6cc79552b 100644 --- a/src/__tests__/e2e/init-ends-with-pull.test.ts +++ b/src/__tests__/e2e/init-ends-with-pull.test.ts @@ -134,11 +134,16 @@ describe.skipIf(process.platform === 'win32')('teamai init ends with a pull', () 'sharing:', ' mcp:', ' autoApply: true', + ' hooks:', + ' autoApply: true', + ' requireTeamScripts: false', '', ].join('\n')); writeSeed(seed, 'skills/team-skill/SKILL.md', '---\nname: team-skill\ndescription: Team skill fixture\n---\n\n# Team skill\n'); writeSeed(seed, 'rules/team-rule.md', '# Team rule\n'); + writeSeed(seed, 'agents/team-agent.yaml', 'name: team-agent\ndescription: Startup agent fixture\ninstructions: Team agent v1\n'); + writeSeed(seed, 'hooks/hooks.yaml', 'hooks:\n - id: startup-guard\n description: Startup hook fixture\n event: SessionStart\n command: echo team-hook-v1\n'); writeSeed(seed, 'mcp/mcp.yaml', [ 'servers:', ' - name: team-api', @@ -166,7 +171,7 @@ describe.skipIf(process.platform === 'win32')('teamai init ends with a pull', () if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); }); - it('user scope: the team skill, rule and MCP server are delivered when init exits', async () => { + it('user scope: skills, agents, rules, MCP and team hooks are delivered when init exits', async () => { const cwd = path.join(sandbox, 'elsewhere'); fs.mkdirSync(cwd, { recursive: true }); const result = await runCLI(['init', FAKE_URL, '--scope', 'user', '--agent', 'claude', '--force'], cwd); @@ -179,6 +184,8 @@ describe.skipIf(process.platform === 'win32')('teamai init ends with a pull', () mcpServers?: Record; }; expect(Object.keys(claudeJson.mcpServers ?? {})).toContain('team-api'); + expect(fs.readFileSync(path.join(home, '.claude', 'agents', 'team-agent.md'), 'utf8')).toContain('Team agent v1'); + expect(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8')).toContain('echo team-hook-v1'); }, 90_000); it('project scope with --agent claude: .claude/ is created and filled, .mcp.json holds the team server', async () => { @@ -193,6 +200,8 @@ describe.skipIf(process.platform === 'win32')('teamai init ends with a pull', () mcpServers?: Record; }; expect(Object.keys(mcp.mcpServers ?? {})).toContain('team-api'); + expect(fs.readFileSync(path.join(project, '.claude', 'agents', 'team-agent.md'), 'utf8')).toContain('Team agent v1'); + expect(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8')).toContain('echo team-hook-v1'); // Only the chosen tool's root, although Codex is installed too. expect(fs.existsSync(path.join(project, '.codex'))).toBe(false); }, 90_000); From 49d16d2233177684c4c63bfdbf3fcb6ec5d8214b Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 13:47:24 +0200 Subject: [PATCH 11/14] docs: require preparation for hosts that skip checkout hooks --- docs/usage-guide.md | 8 ++++++-- docs/usage-guide.zh-CN.md | 9 ++++++--- skill-data/core/SKILL.md | 6 ++++-- skill-data/core/references/troubleshooting.md | 6 ++++++ 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index f87cbe0cf..2e8c983bd 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -177,14 +177,18 @@ A new worktree does not wait for that first session. In project scope, `teamai i and `teamai pull` install a git hook in the repository's local git config, shared by every worktree: `hook.teamai-post-checkout` and `hook.teamai-post-merge` (Git 2.54 or later). Git runs it beside any `core.hooksPath` hook -manager and any `.git/hooks` script. When `git worktree add`, or an app that creates -worktrees, makes a new checkout, the hook creates the project roots of +manager and any `.git/hooks` script. When `git worktree add`, or an app that runs +the same checkout hooks, makes a new checkout, the hook creates the project roots of `enabledAgents` (when that is empty, the roots the main checkout has) and pulls into the worktree before the command returns, so the first session there already has the team's skills, rules and MCP servers. That pull reads the team clone as it is when it was fetched in the last 24 hours (and fetches it first otherwise), and subscribed sources from their cached clones; a full `teamai pull --silent` then runs in the background to fetch the team repo, sources, learnings and reports. A branch switch does nothing. +Hosts that skip checkout hooks need a setup step that finishes `teamai pull` before +the AI tool starts. For Codex CLI 0.160.0, create the checkout with `git worktree add`, run +`teamai pull` there, then launch `codex exec -C `; its native +`codex exec --worktree` path skips `post-checkout`. After `git pull` (`post-merge`), the hook fetches the team repo, waiting at most 5 seconds, and delivers its changes before `git pull` returns; past 5 seconds, and for sources, learnings and reports, the same background pull takes over. In single-repo mode it diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 1e10b3dfa..7ec6d3120 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -167,12 +167,15 @@ teamai init https://github.com/yourorg/yourrepo 新 worktree 不必等到第一次会话。在项目 scope 下,`teamai init` 与 `teamai pull` 会在仓库的本地 git 配置中安装一个 git hook,所有 worktree 共用:`hook.teamai-post-checkout` 与 `hook.teamai-post-merge`(需要 Git 2.54 或更高版本)。Git 会在任何 -`core.hooksPath` hook 管理器和 `.git/hooks` 脚本之外一并运行它。当 `git worktree add`,或会创建 -worktree 的应用,新建一个检出时,该 hook 会创建 `enabledAgents` 的项目根目录(为空时,取主检出已有的根目录), +`core.hooksPath` hook 管理器和 `.git/hooks` 脚本之外一并运行它。当 `git worktree add`,或运行相同 +checkout hook 的应用,新建一个检出时,该 hook 会创建 `enabledAgents` 的项目根目录(为空时,取主检出已有的根目录), 并在命令返回前向该 worktree 执行 pull,因此其中的第一次会话就已具备团队的 skill、rule 与 MCP 服务器。 团队仓库克隆若在 24 小时内 fetch 过,这次 pull 直接读取它(否则先 fetch),订阅的 source 读取其缓存克隆; 随后在后台运行一次完整的 `teamai pull --silent`,fetch 团队仓库、source、learnings 与 reports。 -切换分支不会触发任何操作。`git pull` 之后(`post-merge`),该 hook 会 fetch 团队仓库(最多等待 5 秒), +切换分支不会触发任何操作。跳过 checkout hook 的宿主需要在 AI 工具启动前完成 `teamai pull` 的准备步骤。 +Codex CLI 0.160.0 请先用 `git worktree add` 创建检出,在其中执行 `teamai pull`,再用 +`codex exec -C ` 启动;原生 `codex exec --worktree` 路径会跳过 `post-checkout`。 +`git pull` 之后(`post-merge`),该 hook 会 fetch 团队仓库(最多等待 5 秒), 并在 `git pull` 返回前交付其变更;超过 5 秒时,以及 source、learnings 与 reports,交给同样的后台 pull。 单仓库模式下,它交付 `git pull` 刚带来的知识,不访问网络。该 hook 不输出任何内容且始终以 0 退出, 因此 pull 失败也不会让 git 命令失败。hook 内的失败(团队仓库 fetch 失败,或在 5 秒上限处被中止而后台 pull diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index b8e693c93..8ddec5235 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -131,7 +131,7 @@ In project scope, `init` and `pull` also install a git hook in the repository's local git config (`hook.teamai-post-checkout`, `hook.teamai-post-merge`; Git 2.54+; older Git without `core.hooksPath` gets a marked block in `.git/hooks/` scripts, and with it `teamai doctor` advises), beside any `core.hooksPath` manager or `.git/hooks` script. When a -worktree is created (`git worktree add`, or an app), it creates the project roots +worktree is created by `git worktree add` or an app that runs checkout hooks, it creates the project roots of `enabledAgents` (else the ones the main checkout has) and pulls into it before the command returns, from the team clone as last fetched when that was within 24 h; a full pull then runs in the background. A branch switch does nothing. @@ -141,7 +141,9 @@ exits 0; a failure inside it is recorded, and `teamai doctor` names it (`Last gi hook run failed: ...`) with its fix, as does the next interactive `teamai pull`, once. `teamai doctor` also reports whether the hook is installed, and why not. `pull --dry-run` says when it would install or update the hook, writing nothing; -`teamai uninstall` removes only teamai's hook entries and blocks. +`teamai uninstall` removes only teamai's hook entries and blocks. For hosts that +skip checkout hooks, prepare the worktree before launch; see the new-worktree +section in `references/troubleshooting.md`. A team agent (`agents/.yaml`) can set `model: strong`, `model: fast`, or an alias the team defines, instead of one tool's model. The team maps each alias per diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 56cba2835..c7f79c332 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -85,6 +85,12 @@ hook there is not a shell script), in which case doctor's fix says to upgrade Gi or, if the team agrees, to commit its guarded `command -v teamai ... || true` line into the manager's post-checkout and post-merge hooks. +Hosts that skip checkout hooks need `teamai pull` in the new checkout before the AI +tool starts. For Codex CLI 0.160.0, use `git worktree add`, run `teamai pull` there, then +launch `codex exec -C `. Its native `codex exec --worktree` path creates +the checkout without `post-checkout`, so SessionStart sync arrives after startup +discovery. + ## "KEY is not set. Run `teamai env set KEY`" `pull`, `teamai mcp list`, `teamai env list`, `teamai doctor` and From 4ee6cd08300a955c4ac94dc35acbd292228ab76a Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 14:57:39 +0200 Subject: [PATCH 12/14] fix(hooks): keep existing git hooks intact and report git hook failures - Leave an unreadable or non-executable .git/hooks script alone instead of replacing or enabling it; doctor explains the fallback. - Install the git hook after the agent hooks and propagate its failure with the fix; init reports it and still pulls, hooks inject exits non-zero. - Record why a git hook synced nothing when the project config is unreadable. - Cap the self-mode post-merge lock wait at the same 5 s as the fetch. - Keep a partial delivery failure recorded until a complete sync. - Join detached hook pulls before the e2e sandbox is removed. --- docs/usage-guide.md | 16 +++-- docs/usage-guide.zh-CN.md | 10 +-- skill-data/core/references/troubleshooting.md | 9 ++- src/__tests__/detached-processes.test.ts | 26 +++++++ .../e2e/git-hook-new-worktree.test.ts | 14 ++-- src/__tests__/git-hook.test.ts | 38 +++++++++- src/__tests__/helpers/detached-processes.ts | 43 +++++++++++ src/__tests__/hook-dispatch-cli.test.ts | 19 +++++ src/__tests__/hook-handlers.test.ts | 9 +++ src/__tests__/hooks-cmd.test.ts | 6 ++ src/__tests__/hooks-reconcile-scope.test.ts | 19 +++++ src/__tests__/pull-sync-truth.test.ts | 32 +++++++++ src/git-hook.ts | 15 ++-- src/hook-dispatch-cli.ts | 8 +++ src/hook-handlers.ts | 6 +- src/hooks.ts | 24 ++++--- src/init.ts | 10 ++- src/pull.ts | 71 +++++++++++++++---- 18 files changed, 324 insertions(+), 51 deletions(-) create mode 100644 src/__tests__/detached-processes.test.ts create mode 100644 src/__tests__/helpers/detached-processes.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 2e8c983bd..6f8ba3945 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -195,12 +195,15 @@ learnings and reports, the same background pull takes over. In single-repo mode delivers the knowledge `git pull` just brought, with no network. The hook prints nothing and always exits 0, so a failed pull never fails the git command. A failure inside it (the team repo fetch failed, or stopped at the 5-second cap and the background pull did not finish it; another teamai process held the -project's sync lock longer than the hook waits, 5 seconds after `git pull` and 60 seconds -for a new worktree) is written to `~/.teamai/debug.log` and recorded: `teamai doctor` +project's sync lock longer than the hook waits, 5 seconds after `git pull` (including +single-repo mode) and 60 seconds for a new worktree; incomplete resource, hook or MCP +delivery) is written to `~/.teamai/debug.log` and recorded: `teamai doctor` names it with its fix, and the next interactive `teamai pull` mentions it once. The -background pull retries, and a hook pull that succeeds clears the record. `teamai doctor` +background pull retries, and a hook pull clears the record only after all startup delivery +stages succeed. `teamai doctor` also reports whether the hook is installed and, when it is not, why. It follows the scope rules below: no project config, or one -that cannot be read, means no sync. The command is one `sh` line that runs +that cannot be read, means no sync; an unreadable config's reason is kept in +`~/.teamai/debug.log`. The command is one `sh` line that runs `teamai hook-dispatch --tool git` with Git's arguments, finding `teamai` through `~/.teamai/bin` as the agent hooks do. @@ -209,12 +212,15 @@ With Git older than 2.54 and no `core.hooksPath`, teamai instead adds a block be and `.git/hooks/post-merge`, right after the shebang, creating the script when there is none; the script's other lines are kept. The block runs the same command, silently, and does not change the script's exit status. With `core.hooksPath` set (a hook manager), or -a hook script that is not a shell script, teamai writes nothing, and `teamai doctor` +a hook script that is not an executable shell script, teamai writes nothing, and `teamai doctor` advises: upgrade Git to 2.54 or later; or, if the team agrees to commit it, run `command -v teamai >/dev/null 2>&1 && teamai hook-dispatch --tool git "$@" >/dev/null 2>&1 || true` from the post-checkout and post-merge hooks your manager defines (with `post-checkout` or `post-merge` as ``), wrapped in `sh -c '...'` when its config is not a shell script. That line does nothing on a machine without teamai. +Existing hook contents and permissions are preserved. Reading or writing a hook can +fail: `init` and `hooks inject` propagate that error; a Git-started pull records it +and the next `teamai pull` retries. Once Git is 2.54 or later, the next `teamai pull` installs the config hook and takes the block out, so the hook does not run twice. `teamai pull --dry-run` says when it would diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 7ec6d3120..5fb9da94f 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -179,22 +179,24 @@ Codex CLI 0.160.0 请先用 `git worktree add` 创建检出,在其中执行 `t 并在 `git pull` 返回前交付其变更;超过 5 秒时,以及 source、learnings 与 reports,交给同样的后台 pull。 单仓库模式下,它交付 `git pull` 刚带来的知识,不访问网络。该 hook 不输出任何内容且始终以 0 退出, 因此 pull 失败也不会让 git 命令失败。hook 内的失败(团队仓库 fetch 失败,或在 5 秒上限处被中止而后台 pull -也未完成;另一个 teamai 进程持有项目的同步锁,超过 hook 的等待时间:`git pull` 之后 5 秒,新 worktree 60 秒) +也未完成;另一个 teamai 进程持有项目的同步锁,超过 hook 的等待时间:`git pull` 之后 5 秒(包括单仓库模式),新 worktree 60 秒;资源、hook 或 MCP 未完整交付) 会写入 `~/.teamai/debug.log` 并被记录:`teamai doctor` 会指出它及其修复方法,下一次交互式 `teamai pull` -会提示一次。后台 pull 会重试,任何一次成功的 hook pull 都会清除该记录。`teamai doctor` 还会报告 hook +会提示一次。后台 pull 会重试,只有所有启动交付阶段都成功后,hook pull 才会清除该记录。`teamai doctor` 还会报告 hook 是否已安装,未安装时说明原因。它遵循下文的 scope 规则:没有项目配置,或项目配置无法读取, -都不会同步。其命令是一行 `sh`,带着 Git 传入的参数运行 `teamai hook-dispatch --tool git`, +都不会同步;无法读取配置的原因保留在 `~/.teamai/debug.log` 中。其命令是一行 `sh`,带着 Git 传入的参数运行 `teamai hook-dispatch --tool git`, 与 Agent hook 一样通过 `~/.teamai/bin` 找到 `teamai`。 Git 低于 2.54 且未设置 `core.hooksPath` 时,teamai 改为在 `.git/hooks/post-checkout` 与 `.git/hooks/post-merge` 的 shebang 之后插入一段位于 `# >>> teamai git hook` 与 `# <<< teamai git hook <<<` 标记之间的代码块(脚本不存在时会创建),脚本的其他行保持不变。该代码块运行同一条命令,不输出任何内容, -也不改变脚本的退出码。设置了 `core.hooksPath`(hook 管理器),或 hook 脚本不是 shell 脚本时,teamai +也不改变脚本的退出码。设置了 `core.hooksPath`(hook 管理器),或 hook 脚本不是可执行的 shell 脚本时,teamai 不写入任何内容,`teamai doctor` 会建议:将 Git 升级到 2.54 或更高版本;或者,如果团队同意提交它,在管理器定义的 post-checkout 与 post-merge hook 中运行 `command -v teamai >/dev/null 2>&1 && teamai hook-dispatch --tool git "$@" >/dev/null 2>&1 || true` (`` 分别为 `post-checkout` 与 `post-merge`),管理器的配置不是 shell 脚本时用 `sh -c '...'` 包裹。 在没有 teamai 的机器上,这一行什么也不做。 +已有 hook 的内容和权限保持不变。读取或写入 hook 失败时,`init` 与 `hooks inject` 会传播该错误; +由 Git 启动的 pull 会记录错误,下一次 `teamai pull` 会重试。 Git 升级到 2.54 或更高版本后,下一次 `teamai pull` 会安装配置 hook 并移除该代码块,避免 hook 运行两次。 `teamai pull --dry-run` 会说明是否将安装或更新该 hook,但不写入任何内容。在项目中运行 `teamai uninstall` diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index c7f79c332..f2d876d3b 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -75,15 +75,20 @@ silently and always exits 0, so its failures surface only here: `teamai doctor` names the last one with its fix, and the next interactive `teamai pull` says it once. The causes are a team repo fetch that failed or hit the 5 s post-merge cap without the background pull finishing it, and another teamai process -holding the project's sync lock longer than the hook waits. Run `teamai pull` +holding the project's sync lock longer than the hook waits, or incomplete resource, +hook or MCP delivery. Only a complete startup sync clears the recorded failure. +Run `teamai pull` in the checkout (after a stuck pull ends, or once the team repo is reachable); `~/.teamai/debug.log` has the details. If doctor reports `Git hook syncs new worktrees and git pull` as failing, follow its fix: `teamai pull` installs it. Git older than 2.54 has no config hooks: teamai then adds a marked block to `.git/hooks/post-checkout` and `post-merge`, unless `core.hooksPath` is set (or a -hook there is not a shell script), in which case doctor's fix says to upgrade Git +hook there is not an executable shell script), in which case doctor's fix says to upgrade Git or, if the team agrees, to commit its guarded `command -v teamai ... || true` line into the manager's post-checkout and post-merge hooks. +Existing hook contents and permissions stay unchanged; read/write errors propagate +from `init` and `hooks inject`, and Git-started pulls record them. An unreadable +project config prevents sync and keeps its reason in `~/.teamai/debug.log`. Hosts that skip checkout hooks need `teamai pull` in the new checkout before the AI tool starts. For Codex CLI 0.160.0, use `git worktree add`, run `teamai pull` there, then diff --git a/src/__tests__/detached-processes.test.ts b/src/__tests__/detached-processes.test.ts new file mode 100644 index 000000000..caa1f2863 --- /dev/null +++ b/src/__tests__/detached-processes.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { trackDetachedProcesses } from './helpers/detached-processes.js'; + +it('waits for a detached writer after its spawning parent has exited', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-detached-fixture-')); + const tracker = trackDetachedProcesses(root); + const output = path.join(root, 'late-write'); + try { + const childCode = `setTimeout(() => require('node:fs').writeFileSync(${JSON.stringify(output)}, 'finished'), 300)`; + const parent = spawnSync(process.execPath, ['-e', ` + require('node:child_process').spawn(process.execPath, ['-e', ${JSON.stringify(childCode)}], { + detached: true, stdio: 'ignore' + }).unref(); + `], { env: { ...process.env, NODE_OPTIONS: tracker.nodeOptions } }); + expect(parent.status, parent.stderr.toString()).toBe(0); + await tracker.waitForExit(); + expect(fs.readFileSync(output, 'utf8')).toBe('finished'); + } finally { + await tracker.waitForExit(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/__tests__/e2e/git-hook-new-worktree.test.ts b/src/__tests__/e2e/git-hook-new-worktree.test.ts index 2f0a3347d..608f94b40 100644 --- a/src/__tests__/e2e/git-hook-new-worktree.test.ts +++ b/src/__tests__/e2e/git-hook-new-worktree.test.ts @@ -15,6 +15,7 @@ import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { trackDetachedProcesses } from '../helpers/detached-processes.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..', '..', '..'); @@ -46,11 +47,13 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc let home: string; let remote: string; let claudeProject: string; + let detached: ReturnType; const env = (extra: Record = {}): NodeJS.ProcessEnv => { const base: NodeJS.ProcessEnv = { ...process.env, ...GIT_ENV, HOME: home, USERPROFILE: home, FORCE_COLOR: '0', ...extra }; delete base.CLAUDE_CONFIG_DIR; delete base.CODEX_HOME; + base.NODE_OPTIONS = [base.NODE_OPTIONS, detached.nodeOptions].filter(Boolean).join(' '); return base; }; @@ -115,6 +118,7 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); sandbox = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-git-hook-e2e-'))); + detached = trackDetachedProcesses(sandbox); home = path.join(sandbox, 'home'); remote = path.join(sandbox, 'team.git'); const seed = path.join(sandbox, 'seed'); @@ -138,10 +142,12 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc claudeProject = project('claude-project', ['--agent', 'claude']); }, 60_000); - afterAll(() => { - // Detached pulls of the last worktrees may still be writing. - if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }); - }); + afterAll(async () => { + // The parent hook exits before its child finishes. Join every child before + // deleting HOME; a moment without a sync lock does not mean it has exited. + if (detached) await detached.waitForExit(); + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + }, 65_000); it('init installs one named hook per git event in the repository config', () => { expect(gitOk(['hook', 'list', 'post-checkout'], claudeProject)).toBe('teamai-post-checkout'); diff --git a/src/__tests__/git-hook.test.ts b/src/__tests__/git-hook.test.ts index 8291597ae..4d771db32 100644 --- a/src/__tests__/git-hook.test.ts +++ b/src/__tests__/git-hook.test.ts @@ -1,6 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { execFileSync, spawnSync } from 'node:child_process'; import fs from 'node:fs'; +import fse from 'fs-extra'; import os from 'node:os'; import path from 'node:path'; @@ -209,6 +210,41 @@ describe('teamai hook script on a Git without config hooks', () => { expect(fs.readFileSync(`${hookFile('post-checkout')}.log`, 'utf8')).toBe('mine\n'); }); + it('does not replace an existing hook when reading it fails', async () => { + const file = hookFile('post-checkout'); + const original = '#!/bin/sh\necho owner-hook-content\n'; + fs.writeFileSync(file, original, { mode: 0o755 }); + const read = fse.readFile.bind(fse); + const spy = vi.spyOn(fse, 'readFile').mockImplementation(((...args: Parameters) => { + if (args[0] === file) return Promise.reject(Object.assign(new Error('permission denied'), { code: 'EACCES' })); + return read(...args); + }) as typeof read); + try { + await expect(installGitHook(repo)).rejects.toThrow('permission denied'); + expect(fs.readFileSync(file, 'utf8')).toBe(original); + expect(fs.existsSync(hookFile('post-merge'))).toBe(false); + } finally { + spy.mockRestore(); + } + }); + + it.skipIf(process.platform === 'win32')('leaves a disabled owner hook and its mode untouched', async () => { + const file = hookFile('post-checkout'); + const original = '#!/bin/sh\nexit 42\n'; + fs.writeFileSync(file, original, { mode: 0o644 }); + expect(await installGitHook(repo)).toEqual({ installed: false, reason: 'other-hook' }); + expect(fs.readFileSync(file, 'utf8')).toBe(original); + expect(fs.statSync(file).mode & 0o777).toBe(0o644); + expect(fs.existsSync(hookFile('post-merge'))).toBe(false); + expect(run(['worktree', 'add', '-q', path.join(sandbox, 'disabled-wt')]).status).toBe(0); + }); + + it.skipIf(process.platform === 'win32')('preserves the permissions of an executable owner hook', async () => { + fs.writeFileSync(hookFile('post-checkout'), '#!/bin/sh\nexit 0\n', { mode: 0o700 }); + await installGitHook(repo); + expect(fs.statSync(hookFile('post-checkout')).mode & 0o777).toBe(0o700); + }); + it('leaves a core.hooksPath manager\'s files alone, and doctor advises upgrading or a guarded line', async () => { const managed = path.join(sandbox, 'managed'); fs.mkdirSync(managed); diff --git a/src/__tests__/helpers/detached-processes.ts b/src/__tests__/helpers/detached-processes.ts new file mode 100644 index 000000000..03d4d2f5c --- /dev/null +++ b/src/__tests__/helpers/detached-processes.ts @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** Track detached children before their parent exits, so a fixture can join them. */ +export function trackDetachedProcesses(root: string): { nodeOptions: string; waitForExit: () => Promise } { + const children = path.join(root, 'detached-children'); + fs.mkdirSync(children); + const preload = path.join(root, 'track-detached.cjs'); + fs.writeFileSync(preload, ` +const fs = require('node:fs'); +const path = require('node:path'); +const cp = require('node:child_process'); +const spawn = cp.spawn; +cp.spawn = function (...args) { + const child = spawn.apply(this, args); + if (args[2]?.detached && child.pid) { + fs.writeFileSync(path.join(${JSON.stringify(children)}, String(child.pid)), ''); + } + return child; +}; +require('node:module').syncBuiltinESMExports(); +`); + return { + nodeOptions: `--require ${JSON.stringify(preload)}`, + async waitForExit() { + const deadline = Date.now() + 60_000; + while (true) { + const pending = fs.readdirSync(children); + if (pending.length === 0) return; + for (const name of pending) { + try { + process.kill(Number(name), 0); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ESRCH') throw e; + fs.unlinkSync(path.join(children, name)); + } + } + if (Date.now() >= deadline) throw new Error(`Detached fixture processes did not exit: ${fs.readdirSync(children).join(', ')}`); + await new Promise(resolve => setTimeout(resolve, 25)); + } + }, + }; +} diff --git a/src/__tests__/hook-dispatch-cli.test.ts b/src/__tests__/hook-dispatch-cli.test.ts index c86536227..07cdba90d 100644 --- a/src/__tests__/hook-dispatch-cli.test.ts +++ b/src/__tests__/hook-dispatch-cli.test.ts @@ -57,6 +57,25 @@ describe('deriveDispatchSessionId', () => { }); describe('hookDispatchCli', () => { + it('records why a Git hook cannot sync an unreadable project config, without dispatching', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'git-hook-broken-config-')); + const previousCwd = process.cwd(); + const persist = vi.spyOn(log, 'persist').mockImplementation(() => {}); + try { + execFileSync('git', ['init', '-q'], { cwd: root }); + fs.mkdirSync(path.join(root, '.teamai')); + fs.writeFileSync(path.join(root, '.teamai', 'config.yaml'), 'repo: [invalid'); + process.chdir(root); + mockDispatcher.dispatch.mockClear(); + await hookDispatchCli('post-merge', 'git', '*'); + expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); + expect(persist).toHaveBeenCalledWith(expect.stringMatching(/Nothing was synced:.*config.yaml/s)); + } finally { + process.chdir(previousCwd); + fs.rmSync(root, { recursive: true, force: true }); + persist.mockRestore(); + } + }); it('skips claude hooks only when the other host has its own teamai hooks', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'host-hooks-')); const home = path.join(root, 'home'); diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 6f581b68f..a6b2ce3bb 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -232,6 +232,15 @@ describe('hook-handlers registry', () => { expect(sessionStartHandlers).toContain('dashboard-report'); }); + it('caps the self-mode post-merge lock wait at five seconds', async () => { + const handler = buildHandlerRegistry().find(r => r.event === 'post-merge')!.handler; + await handler.execute({ cwd: '/tmp/self-project' }, 'git', { + scope: 'project', projectRoot: '/tmp/self-project', username: 'test', additionalRoles: [], + repo: { kind: 'self', localPath: '/tmp/self-project', remote: '' }, + }); + expect(mockPull).toHaveBeenCalledWith({ silent: true, inline: true, gitHook: 'post-merge', fetchTimeoutMs: 5000 }); + }); + it('session-start pull seeds the hook tool root before pulling', async () => { const registry = buildHandlerRegistry(); const handler = registry.find( diff --git a/src/__tests__/hooks-cmd.test.ts b/src/__tests__/hooks-cmd.test.ts index eaaa92332..bf5b59917 100644 --- a/src/__tests__/hooks-cmd.test.ts +++ b/src/__tests__/hooks-cmd.test.ts @@ -196,6 +196,12 @@ describe('hooksInject', () => { } }); + it('fails, without the success line, when the git hook cannot be installed', async () => { + mockedReconcileForConfig.mockRejectedValue(new Error('Could not install the teamai git hook in /repo: EACCES')); + await expect(hooksInject({})).rejects.toThrow('Could not install the teamai git hook'); + expect(mockedLog.success).not.toHaveBeenCalled(); + }); + it('propagates error when not initialized', async () => { mockedAutoDetectInit.mockRejectedValue(new Error('teamai is not initialized')); await expect(hooksInject({})).rejects.toThrow('not initialized'); diff --git a/src/__tests__/hooks-reconcile-scope.test.ts b/src/__tests__/hooks-reconcile-scope.test.ts index c4a400ec1..71e4046c1 100644 --- a/src/__tests__/hooks-reconcile-scope.test.ts +++ b/src/__tests__/hooks-reconcile-scope.test.ts @@ -9,6 +9,7 @@ vi.mock('../utils/logger.js', () => ({ })); import { reconcileTeamHooksForConfig } from '../hooks.js'; +import * as gitHook from '../git-hook.js'; import type { LocalConfig, TeamaiConfig } from '../types.js'; let project: string; @@ -74,6 +75,24 @@ afterEach(async () => { }); describe('reconcileTeamHooksForConfig — pull/init core path', () => { + it('propagates Git-hook installation failure instead of reporting a successful reconcile', async () => { + const spy = vi.spyOn(gitHook, 'installGitHook').mockRejectedValueOnce(new Error('EACCES: hooks directory')); + try { + await expect(reconcileTeamHooksForConfig(teamConfig, localConfig())).rejects.toThrow('EACCES: hooks directory'); + } finally { + spy.mockRestore(); + } + }); + it('still installs the agent hooks when the Git hook cannot be installed', async () => { + const spy = vi.spyOn(gitHook, 'installGitHook').mockRejectedValueOnce(new Error('EACCES: hooks directory')); + try { + await expect(reconcileTeamHooksForConfig(teamConfig, localConfig())).rejects.toThrow(/teamai git hook/); + expect((await claudeSettings()).hooks.SessionStart).toHaveLength(1); + expect((await codexSettings()).hooks.SessionStart).toHaveLength(1); + } finally { + spy.mockRestore(); + } + }); it('injects built-in + team hooks into each tool, records the manifest', async () => { await writeYaml(` hooks: diff --git a/src/__tests__/pull-sync-truth.test.ts b/src/__tests__/pull-sync-truth.test.ts index e4d119159..cf217f74d 100644 --- a/src/__tests__/pull-sync-truth.test.ts +++ b/src/__tests__/pull-sync-truth.test.ts @@ -23,6 +23,7 @@ vi.mock('../config.js', async (importOriginal) => ({ vi.mock('../utils/git.js', () => ({ pullRepo: vi.fn().mockResolvedValue('already up to date'), getHeadRev: vi.fn().mockResolvedValue('abc1234'), + listWorktrees: vi.fn().mockResolvedValue([]), })); vi.mock('../utils/logger.js', () => ({ @@ -32,6 +33,7 @@ vi.mock('../utils/logger.js', () => ({ warn: vi.fn(), error: vi.fn(), debug: vi.fn(), + persist: vi.fn(), dim: vi.fn(), }, spinner: vi.fn(() => ({ @@ -67,6 +69,9 @@ import { checkoutKey, pull } from '../pull.js'; import { detectProjectConfig, loadLocalConfigForScope, loadTeamConfig, loadStateForScope, saveStateForScope } from '../config.js'; import { log } from '../utils/logger.js'; import type { TeamaiConfig, LocalConfig } from '../types.js'; +import { recordGitHookFailure, readGitHookFailure } from '../git-hook.js'; +import { reconcileTeamHooksForConfig } from '../hooks.js'; +import { reconcileMcpForConfig } from '../mcp-reconcile.js'; describe('pull reports what reached the tool directory (#585)', () => { let tmpDir: string; @@ -138,6 +143,33 @@ describe('pull reports what reached the tool directory (#585)', () => { await fse.remove(tmpDir); }); + it.each(['docs', 'hooks', 'hook resolution', 'MCP', 'MCP resolution', 'none'])( + 'records partial Git-hook delivery failure in %s and clears it only after a complete retry', async (failure) => { + const projectRoot = path.join(tmpDir, 'project'); + await fse.ensureDir(projectRoot); + const config: LocalConfig = { ...localConfig, scope: 'project', projectRoot }; + vi.mocked(detectProjectConfig).mockResolvedValue(config); + await recordGitHookFailure(config, { + kind: 'hook-error', event: 'post-checkout', at: new Date().toISOString(), error: 'previous failure', + }); + if (failure === 'docs') await fse.outputFile(path.join(projectRoot, 'docs'), 'blocks docs directory'); + if (failure === 'hooks') vi.mocked(reconcileTeamHooksForConfig).mockRejectedValueOnce(new Error('hook write failed')); + if (failure === 'hook resolution') vi.mocked(reconcileTeamHooksForConfig).mockResolvedValueOnce({ ok: false, builtins: 'with-overrides' }); + if (failure === 'MCP') vi.mocked(reconcileMcpForConfig).mockRejectedValueOnce(new Error('MCP write failed')); + if (failure === 'MCP resolution') vi.mocked(reconcileMcpForConfig).mockResolvedValueOnce({ changes: [], wrote: false, unresolved: true }); + await pull({ silent: true, inline: true, gitHook: 'post-checkout', force: true }); + const recorded = await readGitHookFailure(config); + if (failure === 'none') expect(recorded).toBeNull(); + else { + expect(recorded).toMatchObject({ kind: 'hook-error', event: 'post-checkout' }); + expect(recorded && 'error' in recorded && recorded.error).not.toBe('previous failure'); + if (failure === 'docs') await fse.remove(path.join(projectRoot, 'docs')); + await pull({ silent: true, inline: true, gitHook: 'post-checkout' }); + expect(await readGitHookFailure(config)).toBeNull(); + } + }, + ); + /** Every success line this run printed. Read fresh so a prior case cannot leak in. */ function successLines(): string[] { return vi.mocked(log.success).mock.calls.map(([msg]) => String(msg)); diff --git a/src/git-hook.ts b/src/git-hook.ts index eb419a353..56b7e4239 100644 --- a/src/git-hook.ts +++ b/src/git-hook.ts @@ -26,7 +26,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { getDataHome, type LocalConfig } from './types.js'; import { execCommand } from './utils/exec.js'; -import { readJson, remove, writeJson } from './utils/fs.js'; +import { readFileIfExists, readJson, remove, writeJson } from './utils/fs.js'; import { log } from './utils/logger.js'; export const GIT_HOOK_EVENTS = ['post-checkout', 'post-merge'] as const; @@ -125,7 +125,7 @@ export function describeMissingGitHook(status: Exclude { const stale: { file: string; text: string }[] = []; for (const event of GIT_HOOK_EVENTS) { const file = path.join(dir, event); - const current = await fs.readFile(file, 'utf8').catch(() => null); + const current = await readFileIfExists(file); + if (current !== null && process.platform !== 'win32' && ((await fs.stat(file)).mode & 0o111) === 0) { + return { blocked: 'other-hook', stale: [] }; + } const text = withScriptBlock(current, event); if (text === null) return { blocked: 'other-hook', stale: [] }; if (text !== current) stale.push({ file, text }); @@ -194,8 +197,8 @@ async function installHookScripts(git: Git, repoDir: string, opts: { dryRun?: bo ensureTeamaiWrapper(); for (const { file, text } of plan.stale) { await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, text); - await fs.chmod(file, (await fs.stat(file)).mode | 0o111); + // The creation mode applies only to a new file. Existing owner modes stay. + await fs.writeFile(file, text, { mode: 0o755 }); } if (plan.stale.length > 0) log.debug(`git hook: installed teamai hook scripts in ${repoDir}`); return { installed: true, changed: plan.stale.length > 0 }; @@ -219,7 +222,7 @@ async function removeHookScriptBlocks(git: Git, repoDir: string, opts: { dryRun? for (const dir of dirs) { for (const event of GIT_HOOK_EVENTS) { const file = path.join(dir, event); - const current = await fs.readFile(file, 'utf8').catch(() => null); + const current = await readFileIfExists(file); if (current === null) continue; const text = withoutScriptBlock(current); if (text === current) continue; diff --git a/src/hook-dispatch-cli.ts b/src/hook-dispatch-cli.ts index 776f10f73..c9c2aa026 100644 --- a/src/hook-dispatch-cli.ts +++ b/src/hook-dispatch-cli.ts @@ -454,6 +454,14 @@ export async function hookDispatchCli( log.debug(`hook-dispatch: chdir to ${cwd} failed: ${(e as Error).message}`); } } + if (fromGit) { + const { findUnreadableProjectConfig, describeUnreadableConfig } = await import('./config.js'); + const problem = await findUnreadableProjectConfig(cwd); + if (problem !== null) { + log.persist(`git hook: Nothing was synced: ${describeUnreadableConfig(problem)}`); + return; + } + } const localConfig = await resolveHookConfig(stdin, tool); const handlers = filterHandlersForConfig(buildHandlerRegistry(), localConfig); const dispatcher = createDispatcher({ handlers, localConfig }); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 152a8c81e..cbc6d19c9 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -192,9 +192,9 @@ const gitPullHandler: HookHandler = { if (await isWithin(cwd, self ? [getDataHome(config)] : [getDataHome(config), config.repo.localPath])) return null; const { pull } = await import('./pull.js'); - await recordingFailure(config, 'post-merge', () => pull(self - ? { silent: true, inline: true, gitHook: 'post-merge' } - : { silent: true, inline: true, gitHook: 'post-merge', fetchTimeoutMs: POST_MERGE_FETCH_CAP_MS })); + await recordingFailure(config, 'post-merge', () => pull({ + silent: true, inline: true, gitHook: 'post-merge', fetchTimeoutMs: POST_MERGE_FETCH_CAP_MS, + })); if (!self) await spawnDetachedPull(cwd, 'post-merge'); return null; }, diff --git a/src/hooks.ts b/src/hooks.ts index e4d5e32a1..e4c7c6d73 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1874,7 +1874,6 @@ export async function reconcileTeamHooksForConfig( } return resolved.ok ? { ok: true, defs: teamDefs } : { ok: false, builtins: builtinsOnly ?? 'with-overrides' }; } - if (!opts.removeAll) await installProjectGitHook(localConfig); await reconcileHooksToAllTools(hookToolPaths, baseDir, teamDefs, manifestPath, { removeAll: opts.removeAll, builtinOverride: builtin, @@ -1914,8 +1913,11 @@ export async function reconcileTeamHooksForConfig( ); } } + if (!builtinsOnly) await sweepLegacyProjectHooks(teamConfig.toolPaths, localConfig); + // Last, so a repository whose hooks cannot be written still gets the agent + // hooks above; the error then reaches the caller. + if (!opts.removeAll) await installProjectGitHook(localConfig); if (builtinsOnly) return { ok: false, builtins: builtinsOnly }; - await sweepLegacyProjectHooks(teamConfig.toolPaths, localConfig); return { ok: true, defs: teamDefs }; } @@ -1923,21 +1925,23 @@ export async function reconcileTeamHooksForConfig( * Project scope: install teamai's git hook (git-hook.ts) in the repository, so * a new worktree gets the team's resources before `git worktree add` returns. * User scope installs none: its resources live in HOME, which a new worktree - * does not change. A failure is reported and does not stop the caller. + * does not change. Installation errors propagate to the caller. */ async function installProjectGitHook(localConfig: LocalConfig, opts: { dryRun?: boolean } = {}): Promise { if (localConfig.scope !== 'project' || !localConfig.projectRoot) return; const { installGitHook } = await import('./git-hook.js'); + let result: Awaited>; try { - const result = await installGitHook(localConfig.projectRoot, opts); - if (opts.dryRun && result.installed && result.changed) { - log.info(`Would install or update the teamai git hook (post-checkout, post-merge) in ${localConfig.projectRoot}`); - } - if (!result.installed) log.debug(`git hook: not installed in ${localConfig.projectRoot} (${result.reason})`); + result = await installGitHook(localConfig.projectRoot, opts); } catch (e) { - log.warn(`Could not install the teamai git hook in ${localConfig.projectRoot}: ${(e as Error).message}. ` - + 'New worktrees get the team\'s resources at their first session instead; the next `teamai pull` retries.'); + throw new Error(`Could not install the teamai git hook in ${localConfig.projectRoot}: ${(e as Error).message}. ` + + 'New worktrees and `git pull` get the team\'s resources only at the next session. ' + + 'Fix the cause, then run `teamai pull` to install it.', { cause: e }); + } + if (opts.dryRun && result.installed && result.changed) { + log.info(`Would install or update the teamai git hook (post-checkout, post-merge) in ${localConfig.projectRoot}`); } + if (!result.installed) log.debug(`git hook: not installed in ${localConfig.projectRoot} (${result.reason})`); } /** diff --git a/src/init.ts b/src/init.ts index 3fdc6b82c..8ebebc1c2 100644 --- a/src/init.ts +++ b/src/init.ts @@ -788,7 +788,15 @@ async function reconcileHooksForInit( localConfig: LocalConfig, filterAgents: string[] | undefined, ): Promise { - const reconciled = await reconcileTeamHooksForConfig(teamConfig, localConfig, { filterAgents }); + let reconciled: Awaited>; + try { + reconciled = await reconcileTeamHooksForConfig(teamConfig, localConfig, { filterAgents }); + } catch (e) { + // The agent hooks are in place; the rest of init (its pull) still runs. + log.error((e as Error).message); + process.exitCode = 1; + return; + } if (!reconciled.ok) log.warn(describeUnappliedTeamHooks(reconciled)); } diff --git a/src/pull.ts b/src/pull.ts index 2bfcfaef0..a34b70c87 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -926,7 +926,7 @@ async function pullForScope( revisionField?: 'lastPullRev' | 'lastInheritedPullRev'; } = {}, /** Set to `{ completed: true }` on a real (non-dry-run) sync. See pull(). */ - result?: { completed: boolean; docsSyncFailed: boolean; agentModelsHeld: boolean; teamRepoFailed?: boolean }, + result?: { completed: boolean; docsSyncFailed: boolean; agentModelsHeld: boolean; teamRepoFailed?: boolean; resourceSyncFailed?: boolean }, /** Collects this scope's env resolution for the stages after it (see resolvePullEnv). */ teamEnvs?: Map, ): Promise { @@ -955,6 +955,7 @@ async function pullForScope( const refresh = await refreshTeamRepo(localConfig, options); currentRev = refresh.version; submodulesFailed = refresh.submodulesFailed; + if (result && submodulesFailed) result.resourceSyncFailed = true; submodulesChanged = refresh.submodulesChanged; const outcome = `[${scopeLabel}] Team repo: ${refresh.label}`; pullSpin.succeed(outcome); @@ -1028,6 +1029,7 @@ async function pullForScope( // be able to fetch it from the remote instead of skipping forever. const freshConfig = await loadTeamConfig(localConfig.repo.localPath); if (!freshConfig) { + if (result) result.resourceSyncFailed = true; log.warn(`[${scopeLabel}] Team config (teamai.yaml) not found. Skipping.`); return; } @@ -1039,6 +1041,7 @@ async function pullForScope( roleContext = await buildRolePullContext(localConfig); } catch (e) { log.error(`[${scopeLabel}] ${(e as Error).message}`); + if (result) result.resourceSyncFailed = true; return; } @@ -1378,7 +1381,10 @@ async function pullForScope( // resolved set is what removes a deactivated namespace's variables. A // file that cannot be used, or a name defined twice, keeps env.sh as is. const variables = deliverableEnvVariables(await resolvePullEnv(localConfig, roleContext, teamEnvs)); - if (!variables) continue; + if (!variables) { + if (result) result.resourceSyncFailed = true; + continue; + } const countLabel = `${variables.length} env variable(s)`; if (options.dryRun) { @@ -1430,6 +1436,7 @@ async function pullForScope( // Only skills stop: nothing is installed or swept for them this run. log.warn(`[${scopeLabel}] ${describeDeliveryConflict(desired)}. Skills were not updated this run; the installed ones are kept.`); skillsHeld = true; + if (result) result.resourceSyncFailed = true; continue; } items = desired.items; @@ -1445,6 +1452,7 @@ async function pullForScope( // and leaves them alone too. log.warn(`[${scopeLabel}] ${describeDeliveryConflict(desired)}. Agents were not updated this run; the installed ones are kept.`); agentsHeld = true; + if (result) result.resourceSyncFailed = true; continue; } items = desired.items; @@ -2229,7 +2237,8 @@ export async function pull( // into another call. const reported = new Set(); // A later successful scope must not hide an earlier docs failure (or vice versa). - const syncResult = { completed: false, docsSyncFailed: false, agentModelsHeld: false, teamRepoFailed: false }; + const syncResult = { completed: false, docsSyncFailed: false, agentModelsHeld: false, teamRepoFailed: false, resourceSyncFailed: false }; + const startupErrors: string[] = []; // Each scope's env, resolved once by its env stage (resolvePullEnv). const teamEnvs = new Map(); @@ -2288,6 +2297,7 @@ export async function pull( // once the other process finishes syncs it normally. log.info(`[${config.scope}] sync in progress elsewhere — skipped (another pull/push holds the lock)`); contended.add(config); + startupErrors.push(`[${config.scope}] sync lock is held by another teamai process`); // A detached hook pull skipping is fine: the holder syncs. An inline one // leaves the checkout without what the next session reads. if (options.inline && options.gitHook && config.scope === 'project') { @@ -2363,6 +2373,7 @@ export async function pull( } } catch (e) { log.warn(`User-scope pull error: ${(e as Error).message}`); + startupErrors.push(`User-scope pull: ${(e as Error).message}`); } } @@ -2373,13 +2384,10 @@ export async function pull( try { if (await lockScope(projectConfig)) { await pullForScope(projectConfig, options, reported, {}, syncResult, teamEnvs); - if (options.gitHook && !syncResult.teamRepoFailed) { - const { clearGitHookFailure } = await import('./git-hook.js'); - await clearGitHookFailure(projectConfig); - } } } catch (e) { log.warn(`Project-scope pull error: ${(e as Error).message}`); + startupErrors.push(`Project-scope pull: ${(e as Error).message}`); } } @@ -2408,8 +2416,9 @@ export async function pull( if (migrateScope) { try { await reinjectLegacyHooks(migrateScope); - } catch { + } catch (e) { // Non-fatal — pull continues even if hook migration fails. + startupErrors.push(`Hook migration: ${(e as Error).message}`); } } } @@ -2419,11 +2428,11 @@ export async function pull( // what self-heals new built-in hooks and applies hooks.yaml changes on every // session start. In project mode user is null, even when safe resources are // inherited, so executable hook configuration is never composed implicitly. - await reconcileHooksAllScopes(reconcileUser, reconcileProject, options); + startupErrors.push(...await reconcileHooksAllScopes(reconcileUser, reconcileProject, options)); // 3.6. Reconcile team MCP servers. Outside pullForScope for the same reason as // hooks. User-scope MCP remains isolated in project mode. - await reconcileMcpAllScopes(reconcileUser, reconcileProject, options, teamEnvs); + startupErrors.push(...await reconcileMcpAllScopes(reconcileUser, reconcileProject, options, teamEnvs)); // 3.6b. What the member should run for a team secret with no value (#875). // Not on the silent session-start pull: its output is discarded, and it runs @@ -2534,6 +2543,24 @@ export async function pull( await pullSources(sourceConfig, options); } catch (e) { log.debug(`Source pull skipped: ${(e as Error).message}`); + startupErrors.push(`Source skills: ${(e as Error).message}`); + } + } + + // Fetching alone is not success: every startup delivery stage must finish + // before a hook retry may erase the previous failure. Preserve the more + // specific fetch/lock records those stages already wrote. + if (options.gitHook && !options.dryRun && reconcileProject && !syncResult.teamRepoFailed) { + if (syncResult.docsSyncFailed) startupErrors.push('Docs delivery failed'); + if (syncResult.agentModelsHeld) startupErrors.push('Agent models could not be resolved'); + if (syncResult.resourceSyncFailed) startupErrors.push('Resource delivery did not complete'); + const { clearGitHookFailure, recordGitHookFailure } = await import('./git-hook.js'); + if (startupErrors.length > 0) { + await recordGitHookFailure(reconcileProject, { + kind: 'hook-error', event: options.gitHook, at: new Date().toISOString(), error: startupErrors.join('; '), + }); + } else { + await clearGitHookFailure(reconcileProject); } } @@ -2673,7 +2700,8 @@ async function reconcileHooksAllScopes( userConfig: LocalConfig | null, projectConfig: LocalConfig | null, options: GlobalOptions, -): Promise { +): Promise { + const errors: string[] = []; // A dry run still resolves the entries, so the warnings a maintainer runs // `--dry-run` to see — an unknown id, a deprecated per-entry `roles:`, a // hooks.yaml that does not parse — are reported; only the writes are skipped, @@ -2682,7 +2710,10 @@ async function reconcileHooksAllScopes( for (const localConfig of scopes) { try { const teamConfig = await loadTeamConfig(localConfig.repo.localPath); - if (!teamConfig) continue; + if (!teamConfig) { + errors.push(`[${localConfig.scope}] Hooks: team config could not be loaded`); + continue; + } const { reconcileTeamHooksForConfig } = await import('./hooks.js'); const reconciled = await reconcileTeamHooksForConfig(teamConfig, localConfig, { auto: true, @@ -2690,6 +2721,7 @@ async function reconcileHooksAllScopes( filterAgents: localConfig.enabledAgents, dryRun: options.dryRun, }); + if (!reconciled.ok) errors.push(`[${localConfig.scope}] Team hooks could not be resolved`); if (reconciled.ok && reconciled.defs.length > 0) { // Same preview rule as the user-facing line: a dry run resolved and // reported the entries but wrote nothing, so the debug trail must not @@ -2698,8 +2730,10 @@ async function reconcileHooksAllScopes( } } catch (e) { log.debug(`[${localConfig.scope}] Hook reconcile skipped: ${(e as Error).message}`); + errors.push(`[${localConfig.scope}] Hooks: ${(e as Error).message}`); } } + return errors; } /** @@ -2712,7 +2746,8 @@ async function reconcileMcpAllScopes( projectConfig: LocalConfig | null, options: GlobalOptions, teamEnvs: Map, -): Promise { +): Promise { + const errors: string[] = []; // Same contract as the hooks stage: resolve and report the entry warnings on // a dry run, skip the writes. `reconcileMcpForConfig` already gates every // write on `dryRun` (the `mcp inject --dry-run` path uses it), so this only @@ -2721,11 +2756,15 @@ async function reconcileMcpAllScopes( for (const localConfig of scopes) { try { const teamConfig = await loadTeamConfig(localConfig.repo.localPath); - if (!teamConfig) continue; + if (!teamConfig) { + errors.push(`[${localConfig.scope}] MCP: team config could not be loaded`); + continue; + } const { reconcileMcpForConfig } = await import('./mcp-reconcile.js'); - const { changes } = await reconcileMcpForConfig(teamConfig, localConfig, { + const { changes, unresolved } = await reconcileMcpForConfig(teamConfig, localConfig, { force: options.force, dryRun: options.dryRun, teamEnv: await scopeEnv(localConfig, teamEnvs), }); + if (unresolved) errors.push(`[${localConfig.scope}] Team MCP configuration could not be resolved`); const applied = changes.filter((c) => c.action !== 'skipped'); for (const c of changes) { @@ -2744,8 +2783,10 @@ async function reconcileMcpAllScopes( } } catch (e) { log.debug(`[${localConfig.scope}] MCP reconcile skipped: ${(e as Error).message}`); + errors.push(`[${localConfig.scope}] MCP: ${(e as Error).message}`); } } + return errors; } /** From 2a2fc888938b8add84654e3791e6dd94067d8b93 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 19:27:06 +0200 Subject: [PATCH 13/14] fix(hooks): skip teamai's own knowledge worktree in git hooks Detection run inside
/.teamai/knowledge-wt resolves that worktree as its own project root, so the config-based guard missed it: a self-mode push fired post-checkout there, delivered into the disposable worktree and left a detached pull running. Exclude the main checkout's .teamai/ for both git events. --- src/__tests__/hook-handlers.test.ts | 28 ++++++++++++++++++++++++++++ src/hook-handlers.ts | 18 ++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index a6b2ce3bb..6dc991907 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -241,6 +241,34 @@ describe('hook-handlers registry', () => { expect(mockPull).toHaveBeenCalledWith({ silent: true, inline: true, gitHook: 'post-merge', fetchTimeoutMs: 5000 }); }); + it('does not sync teamai\'s own knowledge worktree on post-checkout or post-merge', async () => { + const { execFileSync } = await import('node:child_process'); + const main = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'git-hook-own-wt-'))); + const git = (args: string[], cwd: string) => execFileSync('git', args, { + cwd, stdio: 'ignore', + env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }, + }); + try { + git(['init', '-q', '-b', 'main'], main); + git(['commit', '-q', '--allow-empty', '-m', 'init'], main); + const wt = path.join(main, '.teamai', 'knowledge-wt'); + git(['worktree', 'add', '-q', '--detach', wt, 'HEAD'], main); + // As detection resolves it from inside that worktree: its own checkout. + const config = { + scope: 'project', projectRoot: wt, username: 'test', additionalRoles: [], + repo: { kind: 'self', localPath: path.join(wt, '.teamai'), remote: '' }, + } as unknown as LocalConfig; + const registry = buildHandlerRegistry(); + mockPull.mockClear(); + await registry.find(r => r.event === 'post-checkout')!.handler.execute( + { cwd: wt, git_args: ['0'.repeat(40), 'abc', '1'] }, 'git', config); + await registry.find(r => r.event === 'post-merge')!.handler.execute({ cwd: wt, git_args: ['0'] }, 'git', config); + expect(mockPull).not.toHaveBeenCalled(); + } finally { + fs.rmSync(main, { recursive: true, force: true }); + } + }); + it('session-start pull seeds the hook tool root before pulling', async () => { const registry = buildHandlerRegistry(); const handler = registry.find( diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index cbc6d19c9..86e8f0ed1 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -155,7 +155,7 @@ const newWorktreeHandler: HookHandler = { if (!config || config.scope !== 'project' || !isNewCheckout(args)) return null; const cwd = resolveHookCwd(stdin) ?? process.cwd(); const { getDataHome } = await import('./types.js'); - if (await isWithin(cwd, [getDataHome(config), config.repo.localPath])) return null; + if (await isWithin(cwd, [getDataHome(config), config.repo.localPath, ...await ownCheckoutsDir(cwd)])) return null; await recordingFailure(config, 'post-checkout', async () => { const { createProjectToolRoots } = await import('./project-agent-root.js'); @@ -189,7 +189,8 @@ const gitPullHandler: HookHandler = { const { getDataHome, isSelfMode } = await import('./types.js'); const self = isSelfMode(config); // teamai's own checkouts; in self mode the team repo is the member's. - if (await isWithin(cwd, self ? [getDataHome(config)] : [getDataHome(config), config.repo.localPath])) return null; + const own = [getDataHome(config), ...await ownCheckoutsDir(cwd)]; + if (await isWithin(cwd, self ? own : [...own, config.repo.localPath])) return null; const { pull } = await import('./pull.js'); await recordingFailure(config, 'post-merge', () => pull({ @@ -230,6 +231,19 @@ async function spawnDetachedPull(cwd: string, event: 'post-checkout' | 'post-mer }).on('error', (e) => log.debug(`git hook: detached pull failed to start: ${e.message}`)).unref(); } +/** + * The main checkout's `.teamai/`, where teamai creates its knowledge worktree. + * Detection run from inside that worktree resolves the worktree as its own + * project root, so the config alone cannot tell it is teamai's. + */ +async function ownCheckoutsDir(cwd: string): Promise { + // Git refuses to open a directory that no longer exists. + if (!await pathExists(cwd)) return []; + const { resolveAnchors } = await import('./utils/git.js'); + const anchors = await resolveAnchors(cwd); + return anchors ? [path.join(anchors.projectAnchor, '.teamai')] : []; +} + /** Whether `dir` is one of `parents` or inside one (real paths). */ async function isWithin(dir: string, parents: string[]): Promise { const { realpath } = await import('node:fs/promises'); From f9392dba5e0d257dca0b0858e32b2b394a3d7b35 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Sat, 3 Oct 2026 08:05:39 +0200 Subject: [PATCH 14/14] fix: address review of the pre-session sync - init --scope user pulls the user scope even inside a project checkout - leave symlinked git hook scripts and their targets untouched - clear a recorded git hook failure only after a pull completes --- docs/usage-guide.md | 6 ++-- docs/usage-guide.zh-CN.md | 6 ++-- skill-data/core/references/troubleshooting.md | 2 +- .../e2e/git-hook-new-worktree.test.ts | 14 ++++++++ src/__tests__/e2e/init-ends-with-pull.test.ts | 11 +++++++ src/__tests__/git-hook.test.ts | 11 +++++++ src/git-hook.ts | 14 +++++++- src/init.ts | 3 +- src/pull.ts | 33 ++++++++++--------- src/types.ts | 5 +++ 10 files changed, 81 insertions(+), 24 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index a706f5cb2..7dc6cd5a8 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -198,8 +198,8 @@ fails the git command. A failure inside it (the team repo fetch failed, or stopp project's sync lock longer than the hook waits, 5 seconds after `git pull` (including single-repo mode) and 60 seconds for a new worktree; incomplete resource, hook or MCP delivery) is written to `~/.teamai/debug.log` and recorded: `teamai doctor` -names it with its fix, and the next interactive `teamai pull` mentions it once. The -background pull retries, and a hook pull clears the record only after all startup delivery +names it with its fix, and each interactive `teamai pull` mentions it until one completes. The +background pull retries, and a hook or interactive pull clears the record only after all startup delivery stages succeed. `teamai doctor` also reports whether the hook is installed and, when it is not, why. It follows the scope rules below: no project config, or one that cannot be read, means no sync; an unreadable config's reason is kept in @@ -212,7 +212,7 @@ With Git older than 2.54 and no `core.hooksPath`, teamai instead adds a block be and `.git/hooks/post-merge`, right after the shebang, creating the script when there is none; the script's other lines are kept. The block runs the same command, silently, and does not change the script's exit status. With `core.hooksPath` set (a hook manager), or -a hook script that is not an executable shell script, teamai writes nothing, and `teamai doctor` +a hook script that is a symlink or not an executable shell script, teamai writes nothing, and `teamai doctor` advises: upgrade Git to 2.54 or later; or, if the team agrees to commit it, run `command -v teamai >/dev/null 2>&1 && teamai hook-dispatch --tool git "$@" >/dev/null 2>&1 || true` from the post-checkout and post-merge hooks your manager defines (with `post-checkout` or diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 8fbbefb90..669e18a30 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -180,8 +180,8 @@ Codex CLI 0.160.0 请先用 `git worktree add` 创建检出,在其中执行 `t 单仓库模式下,它交付 `git pull` 刚带来的知识,不访问网络。该 hook 不输出任何内容且始终以 0 退出, 因此 pull 失败也不会让 git 命令失败。hook 内的失败(团队仓库 fetch 失败,或在 5 秒上限处被中止而后台 pull 也未完成;另一个 teamai 进程持有项目的同步锁,超过 hook 的等待时间:`git pull` 之后 5 秒(包括单仓库模式),新 worktree 60 秒;资源、hook 或 MCP 未完整交付) -会写入 `~/.teamai/debug.log` 并被记录:`teamai doctor` 会指出它及其修复方法,下一次交互式 `teamai pull` -会提示一次。后台 pull 会重试,只有所有启动交付阶段都成功后,hook pull 才会清除该记录。`teamai doctor` 还会报告 hook +会写入 `~/.teamai/debug.log` 并被记录:`teamai doctor` 会指出它及其修复方法,每次交互式 `teamai pull` +都会提示,直到某次完成为止。后台 pull 会重试,只有所有启动交付阶段都成功后,hook pull 或交互式 pull 才会清除该记录。`teamai doctor` 还会报告 hook 是否已安装,未安装时说明原因。它遵循下文的 scope 规则:没有项目配置,或项目配置无法读取, 都不会同步;无法读取配置的原因保留在 `~/.teamai/debug.log` 中。其命令是一行 `sh`,带着 Git 传入的参数运行 `teamai hook-dispatch --tool git`, 与 Agent hook 一样通过 `~/.teamai/bin` 找到 `teamai`。 @@ -189,7 +189,7 @@ Codex CLI 0.160.0 请先用 `git worktree add` 创建检出,在其中执行 `t Git 低于 2.54 且未设置 `core.hooksPath` 时,teamai 改为在 `.git/hooks/post-checkout` 与 `.git/hooks/post-merge` 的 shebang 之后插入一段位于 `# >>> teamai git hook` 与 `# <<< teamai git hook <<<` 标记之间的代码块(脚本不存在时会创建),脚本的其他行保持不变。该代码块运行同一条命令,不输出任何内容, -也不改变脚本的退出码。设置了 `core.hooksPath`(hook 管理器),或 hook 脚本不是可执行的 shell 脚本时,teamai +也不改变脚本的退出码。设置了 `core.hooksPath`(hook 管理器),或 hook 脚本是符号链接或不是可执行的 shell 脚本时,teamai 不写入任何内容,`teamai doctor` 会建议:将 Git 升级到 2.54 或更高版本;或者,如果团队同意提交它,在管理器定义的 post-checkout 与 post-merge hook 中运行 `command -v teamai >/dev/null 2>&1 && teamai hook-dispatch --tool git "$@" >/dev/null 2>&1 || true` diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index a0c6e35b2..81928ecc5 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -83,7 +83,7 @@ in the checkout (after a stuck pull ends, or once the team repo is reachable); worktrees and git pull` as failing, follow its fix: `teamai pull` installs it. Git older than 2.54 has no config hooks: teamai then adds a marked block to `.git/hooks/post-checkout` and `post-merge`, unless `core.hooksPath` is set (or a -hook there is not an executable shell script), in which case doctor's fix says to upgrade Git +hook there is a symlink or not an executable shell script), in which case doctor's fix says to upgrade Git or, if the team agrees, to commit its guarded `command -v teamai ... || true` line into the manager's post-checkout and post-merge hooks. Existing hook contents and permissions stay unchanged; read/write errors propagate diff --git a/src/__tests__/e2e/git-hook-new-worktree.test.ts b/src/__tests__/e2e/git-hook-new-worktree.test.ts index 608f94b40..602540f36 100644 --- a/src/__tests__/e2e/git-hook-new-worktree.test.ts +++ b/src/__tests__/e2e/git-hook-new-worktree.test.ts @@ -556,6 +556,20 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc expect(teamai(['doctor'], repo).output).toContain('✔ No git hook failure recorded'); }); + it('an interactive pull that fails too keeps the recorded failure for doctor', async () => { + const repo = project('fail-again-project', ['--agent', 'claude']); + await settle(repo); + const restore = failNextHook(repo); + try { + const wt = worktreeAdd(repo, 'wt-fail-again'); + await settle(repo); + expect(teamai(['pull'], wt.dir).output).toMatch(/Last git hook run failed/); + } finally { + restore(); + } + expect(teamai(['doctor'], repo).output).toMatch(/✖ Last git hook run failed/); + }); + it('a partition lock another pull holds: post-merge waits no longer than its cap and records why it skipped', async () => { const repo = project('locked-project', ['--agent', 'claude']); const bare = `${repo}.git`; diff --git a/src/__tests__/e2e/init-ends-with-pull.test.ts b/src/__tests__/e2e/init-ends-with-pull.test.ts index 6cc79552b..8ae00c881 100644 --- a/src/__tests__/e2e/init-ends-with-pull.test.ts +++ b/src/__tests__/e2e/init-ends-with-pull.test.ts @@ -188,6 +188,17 @@ describe.skipIf(process.platform === 'win32')('teamai init ends with a pull', () expect(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8')).toContain('echo team-hook-v1'); }, 90_000); + it('user scope run inside a project-scoped checkout pulls the user scope it just configured', async () => { + const project = makeBusinessRepo(`app-${Math.random().toString(36).slice(2)}`); + const first = await runCLI(['init', FAKE_URL, '--scope', 'project', '--agent', 'codex', '--force'], project); + expect(first.code, first.output).toBe(0); + + const result = await runCLI(['init', FAKE_URL, '--scope', 'user', '--agent', 'claude', '--force'], project); + expect(result.code, result.output).toBe(0); + expect(fs.existsSync(path.join(home, '.claude', 'skills', 'team-skill', 'SKILL.md')), result.output).toBe(true); + expect(fs.readFileSync(path.join(home, '.claude', 'rules', 'team-rule.md'), 'utf8')).toContain('Team rule'); + }, 120_000); + it('project scope with --agent claude: .claude/ is created and filled, .mcp.json holds the team server', async () => { const project = makeBusinessRepo(`app-${Math.random().toString(36).slice(2)}`); const result = await runCLI(['init', FAKE_URL, '--scope', 'project', '--agent', 'claude', '--force'], project); diff --git a/src/__tests__/git-hook.test.ts b/src/__tests__/git-hook.test.ts index 4d771db32..890665d66 100644 --- a/src/__tests__/git-hook.test.ts +++ b/src/__tests__/git-hook.test.ts @@ -239,6 +239,17 @@ describe('teamai hook script on a Git without config hooks', () => { expect(run(['worktree', 'add', '-q', path.join(sandbox, 'disabled-wt')]).status).toBe(0); }); + it.skipIf(process.platform === 'win32')('leaves a symlinked hook and the script it points to untouched', async () => { + const shared = path.join(sandbox, 'shared-post-checkout'); + const original = '#!/bin/sh\nexit 0\n'; + fs.writeFileSync(shared, original, { mode: 0o755 }); + fs.symlinkSync(shared, hookFile('post-checkout')); + expect(await installGitHook(repo)).toEqual({ installed: false, reason: 'other-hook' }); + expect(fs.readFileSync(shared, 'utf8')).toBe(original); + expect(fs.lstatSync(hookFile('post-checkout')).isSymbolicLink()).toBe(true); + expect(fs.existsSync(hookFile('post-merge'))).toBe(false); + }); + it.skipIf(process.platform === 'win32')('preserves the permissions of an executable owner hook', async () => { fs.writeFileSync(hookFile('post-checkout'), '#!/bin/sh\nexit 0\n', { mode: 0o700 }); await installGitHook(repo); diff --git a/src/git-hook.ts b/src/git-hook.ts index 56b7e4239..ebe92178c 100644 --- a/src/git-hook.ts +++ b/src/git-hook.ts @@ -125,7 +125,7 @@ export function describeMissingGitHook(status: Exclude { const stale: { file: string; text: string }[] = []; for (const event of GIT_HOOK_EVENTS) { const file = path.join(dir, event); + // A symlink usually points at a hook manager's script, possibly shared by other repositories. + if (await isSymlink(file)) return { blocked: 'other-hook', stale: [] }; const current = await readFileIfExists(file); if (current !== null && process.platform !== 'win32' && ((await fs.stat(file)).mode & 0o111) === 0) { return { blocked: 'other-hook', stale: [] }; @@ -222,6 +224,7 @@ async function removeHookScriptBlocks(git: Git, repoDir: string, opts: { dryRun? for (const dir of dirs) { for (const event of GIT_HOOK_EVENTS) { const file = path.join(dir, event); + if (await isSymlink(file)) continue; const current = await readFileIfExists(file); if (current === null) continue; const text = withoutScriptBlock(current); @@ -256,6 +259,15 @@ export async function removeGitHook(repoDir: string, opts: { dryRun?: boolean } return removed; } +async function isSymlink(file: string): Promise { + try { + return (await fs.lstat(file)).isSymbolicLink(); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw e; + } +} + async function ok(result: ReturnType, key: string): Promise { const { code, stderr } = await result; if (code !== 0) throw new Error(`git config ${key} failed: ${stderr.trim() || `exit ${code}`}`); diff --git a/src/init.ts b/src/init.ts index 75536edbf..784eac3c7 100644 --- a/src/init.ts +++ b/src/init.ts @@ -2142,7 +2142,8 @@ export async function init(options: GlobalOptions & { // first session after init runs without them. Failures are reported by pull // in its own words; init itself has succeeded. const { pull } = await import('./pull.js'); - await pull({ verbose: options.verbose, interactive: true }); + // Inside a project checkout, a bare pull would detect that project instead. + await pull({ verbose: options.verbose, interactive: true, userScopeOnly: localConfig.scope === 'user' }); log.success('teamai initialized successfully!'); if (stubDeployed > 0) { diff --git a/src/pull.ts b/src/pull.ts index d24387cf4..8aa9e7af0 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -2184,16 +2184,15 @@ async function reinjectLegacyHooks(localConfig: LocalConfig): Promise { log.debug('Hooks migrated to dispatch format'); } -/** Say the failure the git hook recorded, then drop it: an interactive pull says it once. */ +/** Say the failure the git hook recorded. A pull that then completes clears it (see pull()). */ async function mentionGitHookFailure(config: LocalConfig, reported: Set): Promise { - const { readGitHookFailure, clearGitHookFailure, describeGitHookFailure } = await import('./git-hook.js'); + const { readGitHookFailure, describeGitHookFailure } = await import('./git-hook.js'); const failure = await readGitHookFailure(config); if (!failure) return; const { message, fix } = describeGitHookFailure(failure); log.warn(`Last git hook run failed: ${message}`); log.dim(` → ${fix}`); reported.add('git-hook-failure'); - await clearGitHookFailure(config); } /** @@ -2308,14 +2307,16 @@ export async function pull( // processed at all (issue #73: project install isolates from user). let projectConfig: LocalConfig | null = null; const unreadable: string[] = []; - try { - projectConfig = await detectProjectConfig( - undefined, - (configPath, error) => { unreadable.push(`${configPath}: ${error}`); }, - { dryRun: options.dryRun }, - ); - } catch (e) { - log.warn(`Project-scope detection error: ${(e as Error).message}`); + if (!options.userScopeOnly) { + try { + projectConfig = await detectProjectConfig( + undefined, + (configPath, error) => { unreadable.push(`${configPath}: ${error}`); }, + { dryRun: options.dryRun }, + ); + } catch (e) { + log.warn(`Project-scope detection error: ${(e as Error).message}`); + } } // Detection skips a project config it cannot read and answers with what // loads next — a legacy `.teamai/` that may name another team, or the user @@ -2541,15 +2542,17 @@ export async function pull( } // Fetching alone is not success: every startup delivery stage must finish - // before a hook retry may erase the previous failure. Preserve the more - // specific fetch/lock records those stages already wrote. - if (options.gitHook && !options.dryRun && reconcileProject && !syncResult.teamRepoFailed) { + // before a hook retry, or the interactive pull that mentioned the failure, + // may erase it. Preserve the more specific fetch/lock records those stages + // already wrote. + const retriesHookFailure = Boolean(options.gitHook) || reported.has('git-hook-failure'); + if (retriesHookFailure && !options.dryRun && reconcileProject && !syncResult.teamRepoFailed) { if (syncResult.docsSyncFailed) startupErrors.push('Docs delivery failed'); if (syncResult.agentModelsHeld) startupErrors.push('Agent models could not be resolved'); if (syncResult.resourceSyncFailed) startupErrors.push('Resource delivery did not complete'); const { clearGitHookFailure, recordGitHookFailure } = await import('./git-hook.js'); if (startupErrors.length > 0) { - await recordGitHookFailure(reconcileProject, { + if (options.gitHook) await recordGitHookFailure(reconcileProject, { kind: 'hook-error', event: options.gitHook, at: new Date().toISOString(), error: startupErrors.join('; '), }); } else { diff --git a/src/types.ts b/src/types.ts index d59c6cef2..4812bc40b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1091,6 +1091,11 @@ export interface GlobalOptions { * recorded for `doctor` and the next interactive pull; its success clears them. */ gitHook?: 'post-checkout' | 'post-merge'; + /** + * Internal (`init --scope user`, `pull` only): pull the user scope even when + * the current directory is a project-scoped checkout. + */ + userScopeOnly?: boolean; /** Push a specific skill by path. */ skill?: string; /** Target role namespace (overrides detected namespace). */