diff --git a/docs/usage-guide.md b/docs/usage-guide.md index bb55cb526..cb21bda5b 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -175,7 +175,8 @@ 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 +every worktree: `hook.teamai-post-checkout`, `hook.teamai-post-merge` and +`hook.teamai-post-rewrite` (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 runs the same checkout hooks, makes a new checkout, the hook creates the project roots of @@ -189,10 +190,13 @@ Hosts that skip checkout hooks need a setup step that finishes `teamai pull` bef 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, +After `git pull` (`post-merge`, or `post-rewrite` for a completed rebase, including +`pull.rebase=true`; on Git 2.32 and older, a fast-forward rebase with autostash runs +only `post-checkout`, which syncs the same way), 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 +delivers the knowledge `git pull` just brought, with no network. A conflicting rebase +syncs only when completed; `git commit --amend` does not sync. 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` (including @@ -201,7 +205,11 @@ delivery) is written to `~/.teamai/debug.log` and recorded: `teamai doctor` 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 +also reports whether the hooks are installed and enabled. Git 2.54+ can disable a +named hook (`hook.teamai-.enabled=false`); Git 2.55+ can also disable the +whole event (`hook..enabled=false`). Both settings can be global, local or per-worktree. Doctor checks the effective +Git setting and gives the reactivation command for its scope (a local override, or unsetting a worktree setting, which a local one cannot override); `teamai pull` preserves an explicit +disablement. After enabling it, run `teamai pull` to sync. 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 `~/.teamai/debug.log`. The command is one `sh` line that runs `teamai hook-dispatch --tool git` with Git's arguments, finding `teamai` @@ -209,14 +217,15 @@ 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 +along with `.git/hooks/post-merge` and `.git/hooks/post-rewrite`, 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 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 -`post-merge` as ``), wrapped in `sh -c '...'` when its config is not a shell script. +from the post-checkout, post-merge and post-rewrite hooks your manager defines (with the +corresponding event 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 @@ -225,7 +234,7 @@ 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 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 +the `hook.teamai-post-checkout`, `hook.teamai-post-merge` and `hook.teamai-post-rewrite` 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. @@ -2990,7 +2999,7 @@ What gets removed: - Team-synced rules, including the copies older releases left in `.codex/rules/`, a project's `.workbuddy/rules/` and `.pi/rules/`, `.openclaw/rules/`, `~/.pi/agent/rules/` and `~/.joycode/rules/`, also of rules the team has since removed. A copy in a project's `.codebuddy/rules/` stays while the other of CodeBuddy and WorkBuddy is still installed. 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 +- In a project, teamai's git hook: the `hook.teamai-post-checkout`, `hook.teamai-post-merge` and `hook.teamai-post-rewrite` entries in the repository's git config, and the marked block in `.git/hooks/post-checkout`, `post-merge` and `post-rewrite` (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 d44a34643..7032373f5 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -165,8 +165,8 @@ teamai init https://github.com/yourorg/yourrepo `--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 配置中安装一个 git hook,所有 worktree 共用:`hook.teamai-post-checkout`、 +`hook.teamai-post-merge` 与 `hook.teamai-post-rewrite`(需要 Git 2.54 或更高版本)。Git 会在任何 `core.hooksPath` hook 管理器和 `.git/hooks` 脚本之外一并运行它。当 `git worktree add`,或运行相同 checkout hook 的应用,新建一个检出时,该 hook 会创建 `enabledAgents` 的项目根目录(为空时,取主检出已有的根目录), 并在命令返回前向该 worktree 执行 pull,因此其中的第一次会话就已具备团队的 skill、rule 与 MCP 服务器。 @@ -175,32 +175,37 @@ checkout hook 的应用,新建一个检出时,该 hook 会创建 `enabledAge 切换分支不会触发任何操作。跳过 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` 之后(`post-merge`,或 rebase 完成后的 `post-rewrite`,包括 `pull.rebase=true`;Git 2.32 及更早版本中,开启 autostash 的快进 rebase 只运行 `post-checkout`,同样会同步),该 hook 会 fetch 团队仓库(最多等待 5 秒), 并在 `git pull` 返回前交付其变更;超过 5 秒时,以及 source、learnings 与 reports,交给同样的后台 pull。 -单仓库模式下,它交付 `git pull` 刚带来的知识,不访问网络。该 hook 不输出任何内容且始终以 0 退出, +单仓库模式下,它交付 `git pull` 刚带来的知识,不访问网络。有冲突的 rebase 仅在完成后同步; +`git commit --amend` 不触发同步。该 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 或交互式 pull 才会清除该记录。`teamai doctor` 还会报告 hook -是否已安装,未安装时说明原因。它遵循下文的 scope 规则:没有项目配置,或项目配置无法读取, +是否已安装并启用。Git 2.54+ 可禁用指定 hook +(`hook.teamai-.enabled=false`);Git 2.55+ 还可禁用整个事件 +(`hook..enabled=false`)。两种设置均可写在全局、本地或 worktree 配置中。 +doctor 检查 Git 的实际生效配置,并按其作用域给出重新启用命令(本地覆盖,或删除本地配置无法覆盖的 worktree 设置);`teamai pull` 保留显式禁用设置。 +启用后运行 `teamai pull` 完成同步。它遵循下文的 scope 规则:没有项目配置,或项目配置无法读取, 都不会同步;无法读取配置的原因保留在 `~/.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 <<<` +Git 低于 2.54 且未设置 `core.hooksPath` 时,teamai 改为在 `.git/hooks/post-checkout`、 +`.git/hooks/post-merge` 与 `.git/hooks/post-rewrite` 的 shebang 之后插入一段位于 `# >>> teamai git hook` 与 `# <<< teamai git hook <<<` 标记之间的代码块(脚本不存在时会创建),脚本的其他行保持不变。该代码块运行同一条命令,不输出任何内容, 也不改变脚本的退出码。设置了 `core.hooksPath`(hook 管理器),或 hook 脚本是符号链接或不是可执行的 shell 脚本时,teamai 不写入任何内容,`teamai doctor` 会建议:将 Git 升级到 2.54 或更高版本;或者,如果团队同意提交它,在管理器定义的 -post-checkout 与 post-merge hook 中运行 +post-checkout、post-merge 与 post-rewrite 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 '...'` 包裹。 +(`` 为对应的事件名),管理器的配置不是 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` -会移除 `hook.teamai-post-checkout` 与 `hook.teamai-post-merge` 条目以及带标记的代码块;其他 hook 和脚本行保持不变。 +会移除 `hook.teamai-post-checkout`、`hook.teamai-post-merge` 与 `hook.teamai-post-rewrite` 条目以及带标记的代码块;其他 hook 和脚本行保持不变。 移除后只剩 shebang 的脚本是 teamai 创建的,会被删除。 > **从旧版 teamai 升级?** 升级后首次执行 `teamai init` / `pull` / `push` / `contribute` @@ -2781,7 +2786,7 @@ teamai uninstall --agent claude - 团队同步的 rules,包括旧版本留在 `.codex/rules/`、项目的 `.workbuddy/rules/` 与 `.pi/rules/`、`.openclaw/rules/`、`~/.pi/agent/rules/` 和 `~/.joycode/rules/` 中的副本,团队此后已删除的 rule 的副本也包括在内。项目 `.codebuddy/rules/` 中的副本,只要 CodeBuddy 与 WorkBuddy 中的另一个仍已安装就会保留。清理使用记录的 `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 的 git hook:仓库 git 配置中的 `hook.teamai-post-checkout`、`hook.teamai-post-merge` 与 `hook.teamai-post-rewrite` 条目,以及 `.git/hooks/post-checkout`、`post-merge` 与 `post-rewrite` 中带标记的代码块(移除后只剩 shebang 的脚本是 teamai 创建的,会被删除)。其他 hook 保留 - `~/.teamai/` 目录 ### 只卸载单个工具(`--agent `) diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 8ddec5235..9659fed5f 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -128,18 +128,20 @@ 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 +local git config (`hook.teamai-post-checkout`, `hook.teamai-post-merge`, `hook.teamai-post-rewrite`; 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 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. -After `git pull` it fetches the team repo (5 s cap, then the background pull) and +After `git pull` (merge or a completed rebase) 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; 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. +`teamai doctor` also reports whether the hooks are installed and enabled, with a +reactivation command when Git disables a hook or event. Pull preserves explicit +disablement. A conflicting rebase syncs only on completion; commit amend does not sync. `pull --dry-run` says when it would install or update the hook, writing nothing; `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 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index ecdded459..41eac0d32 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -85,9 +85,12 @@ This is the #1 onboarding issue. In order: ## "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` +through merge or a completed rebase (`post-rewrite`, including `pull.rebase=true`; on Git 2.32 +and older, a fast-forward rebase with autostash syncs on `post-checkout`). +A conflicting rebase syncs only on completion; `git commit --amend` does not sync. It runs 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 +once. The causes are a team repo fetch that failed or hit the 5 s git-pull hook cap without the background pull finishing it, and another teamai process 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. @@ -96,10 +99,14 @@ 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 +`.git/hooks/post-checkout`, `post-merge` and `post-rewrite`, unless `core.hooksPath` is set (or a 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. +line into the manager's post-checkout, post-merge and post-rewrite hooks. +Doctor also detects hooks disabled by name (Git 2.54+) or event (Git 2.55+) +in effective global, local or worktree config. Follow its command (`git config --local true`, +or `git config --worktree --unset ` for a worktree setting) to enable the named hook or event, then run `teamai pull` to sync. Pull preserves +explicit disable settings, so reinstalling alone does not enable a disabled hook. 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`. diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 73e8b0c6f..3413192cd 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -102,9 +102,9 @@ and give it your team repo URL."* - 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. + `hook.teamai-post-checkout`, `hook.teamai-post-merge` and `hook.teamai-post-rewrite` entries in the repo's git + config and the `# >>> teamai git hook` block in `.git/hooks/post-checkout`, + `post-merge` and `post-rewrite`. 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 d4aa39d1c..bdc8684f1 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. `git pull` (`post-merge`) brings the team's change the same way. + * new worktree. `git pull` (`post-merge`, `post-rewrite`, or a fast-forward rebase's `post-checkout`) 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. @@ -31,11 +31,10 @@ const GIT_ENV = { 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); -})(); +const versionMatch = /(\d+)\.(\d+)/.exec(execFileSync('git', ['--version'], { encoding: 'utf8' })); +const [gitMajor, gitMinor] = versionMatch ? [Number(versionMatch[1]), Number(versionMatch[2])] : [0, 0]; +const configHooks = gitMajor > 2 || (gitMajor === 2 && gitMinor >= 54); +const eventDisable = gitMajor > 2 || (gitMajor === 2 && gitMinor >= 55); interface Run { code: number | null; @@ -155,6 +154,7 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc 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'); + expect(gitOk(['hook', 'list', 'post-rewrite'], claudeProject)).toBe('teamai-post-rewrite'); }); it('delivers skills, agents, rules, MCP and team hooks for enabledAgents before git worktree add returns, silently', () => { @@ -406,7 +406,7 @@ 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', () => { + describe('git pull 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`; @@ -425,31 +425,57 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc }; }; - it('separate team repo: published resources are delivered before git pull returns', async () => { - const repo = project('merge-project', ['--agent', 'claude']); + it('amending a commit does not fetch or sync pending team resources', async () => { + const repo = project('amend-project', ['--agent', 'claude']); + await settle(repo); + pushSkill('amend-only-skill'); + const trace = path.join(sandbox, 'amend-trace.log'); + + const r = git(['commit', '--amend', '-q', '--no-edit'], repo, { GIT_TRACE: trace }); + + expect(r.code, r.output).toBe(0); + expect(r.output).toBe(''); + expect(hasSkill(repo, 'amend-only-skill')).toBe(false); + const traced = fs.readFileSync(trace, 'utf8'); + const fromHook = traced.slice(traced.indexOf('hook-dispatch post-rewrite')); + expect(fromHook).toContain('hook-dispatch post-rewrite'); + expect(fromHook).not.toMatch(/\b(fetch|upload-pack|ls-remote|push|pull)\b/); + await detached.waitForExit(); + expect(hasSkill(repo, 'amend-only-skill')).toBe(false); + }); + + it.each(['merge', 'rebase'])('separate team repo: published resources are delivered before git pull with %s returns', async (mode) => { + const repo = project(`published-${mode}-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'); + const skill = `published-${mode}-skill`; + pushSkill(skill); + const version = mode === 'merge' ? 'v2' : 'v3'; + const teamChange = path.join(sandbox, `push-${skill}`); + fs.writeFileSync(path.join(teamChange, 'agents', 'team-agent.yaml'), `name: team-agent\ndescription: Startup agent fixture\ninstructions: Team agent ${version}\n`); + fs.writeFileSync(path.join(teamChange, 'rules', 'team-rule.md'), `# Team rule ${version}\n`); + fs.writeFileSync(path.join(teamChange, 'mcp', 'mcp.yaml'), `servers:\n - name: team-api\n transport: http\n url: https://team-${version}.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-${version}\n`); gitOk(['add', '-A'], teamChange); gitOk(['commit', '-q', '-m', 'update all startup resources'], teamChange); gitOk(['push', '-q', 'origin', 'HEAD:main'], teamChange); businessChange(); + if (mode === 'rebase') { + fs.writeFileSync(path.join(repo, 'local.txt'), 'local\n'); + gitOk(['add', 'local.txt'], repo); + gitOk(['commit', '-q', '-m', 'local change'], repo); + } - const r = git(['pull', '-q'], repo); + const r = git(['pull', '-q', ...(mode === 'rebase' ? ['--rebase'] : [])], 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); - 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(repo, '.claude', 'settings.local.json'), 'utf8')).toContain('echo team-hook-v2'); + expect(hasSkill(repo, skill)).toBe(true); + expect(fs.readFileSync(path.join(repo, '.claude', 'agents', 'team-agent.md'), 'utf8')).toContain(`Team agent ${version}`); + expect(fs.readFileSync(path.join(repo, '.claude', 'rules', 'team-rule.md'), 'utf8')).toContain(`Team rule ${version}`); + expect(fs.readFileSync(path.join(repo, '.mcp.json'), 'utf8')).toContain(`https://team-${version}.example.com/mcp`); + expect(fs.readFileSync(path.join(repo, '.claude', 'settings.local.json'), 'utf8')).toContain(`echo team-hook-${version}`); await settle(repo); }); @@ -485,8 +511,11 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc 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'); + // Fast-forward with autostash: Git 2.33+ merges (post-merge); Git 2.14–2.32 runs + // `git rebase` under the pull's GIT_REFLOG_ACTION, which only checks out the + // upstream (post-checkout). The legacy mode runs that step as old Git does. + it.each(['merge', 'rebase', 'configured-rebase', 'conflicting-rebase', 'autostash-fast-forward', 'legacy-autostash-fast-forward'])('self mode: git pull with %s delivers a changed rule without hook network', async (mode) => { + const repo = path.join(sandbox, `self-project-${mode}`); fs.mkdirSync(repo); fs.writeFileSync(path.join(repo, 'README'), 'x\n'); gitOk(['init', '-q', '-b', 'main'], repo); @@ -501,19 +530,45 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc 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'); + if (mode === 'conflicting-rebase') fs.writeFileSync(path.join(mate, 'README'), 'remote\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 }); + const fastForward = mode.endsWith('autostash-fast-forward'); + if (fastForward) fs.writeFileSync(path.join(repo, 'README'), 'dirty\n'); + if (mode !== 'merge' && !fastForward) { + const localFile = mode === 'conflicting-rebase' ? 'README' : 'local.txt'; + fs.writeFileSync(path.join(repo, localFile), 'local\n'); + gitOk(['add', localFile], repo); + gitOk(['commit', '-q', '-m', 'local change'], repo); + } + if (mode === 'configured-rebase') gitOk(['config', 'pull.rebase', 'true'], repo); + if (mode === 'autostash-fast-forward') gitOk(['config', 'rebase.autoStash', 'true'], repo); + if (mode === 'legacy-autostash-fast-forward') gitOk(['fetch', '-q', 'origin'], repo); + const trace = path.join(sandbox, `self-trace-${mode}.log`); + + let r = mode === 'legacy-autostash-fast-forward' + ? git(['rebase', '-q', '--autostash', 'origin/main'], repo, { GIT_TRACE: trace, GIT_REFLOG_ACTION: 'pull --rebase --autostash' }) + : git(['pull', '-q', ...(['rebase', 'conflicting-rebase', 'autostash-fast-forward'].includes(mode) ? ['--rebase'] : [])], repo, { GIT_TRACE: trace }); + if (mode === 'conflicting-rebase') { + expect(r.code).not.toBe(0); + expect(fs.existsSync(path.join(repo, '.claude', 'rules', 'self-rule.md'))).toBe(false); + fs.writeFileSync(path.join(repo, 'README'), 'resolved\n'); + gitOk(['add', 'README'], repo); + r = git(['rebase', '--continue'], repo, { GIT_TRACE: trace, GIT_EDITOR: 'true' }); + } expect(r.code, r.output).toBe(0); - expect(r.output).toBe(''); + // Autostash and a resolved conflict print git's own lines; the hook still prints nothing. + if (mode === 'conflicting-rebase' || fastForward) expect(r.output).not.toContain('teamai'); + else expect(r.output).toBe(''); + if (fastForward) expect(fs.readFileSync(path.join(repo, 'README'), 'utf8')).toBe('dirty\n'); 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/); + const event = mode === 'legacy-autostash-fast-forward' ? 'post-checkout' + : ['merge', 'autostash-fast-forward'].includes(mode) ? 'post-merge' : 'post-rewrite'; + const fromHook = traced.slice(traced.indexOf(`hook-dispatch ${event}`)); + expect(fromHook).toContain(`hook-dispatch ${event}`); expect(fromHook).not.toMatch(/\b(fetch|upload-pack|ls-remote|push|pull)\b/); }); }); @@ -538,6 +593,30 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc expect(missing).toMatch(/not in this repository's git config.*Run `teamai pull`/); }); + it.each(['hook.teamai-post-checkout.enabled', 'hook.post-checkout.enabled'].filter(key => key.includes('teamai-') || eventDisable))('doctor reports %s disabled; pull preserves it; reactivation restores delivery', async (key) => { + const repo = project(`doctor-disabled-${key}`, ['--agent', 'claude']); + await settle(repo); + gitOk(['config', '--local', key, 'false'], repo); + const disabled = teamai(['doctor'], repo).output; + expect(disabled).toContain('✖ Git hook syncs new worktrees and git pull'); + expect(disabled).toContain(`${key}=false`); + expect(disabled).toContain(`git config --local ${key} true`); + const pull = teamai(['pull'], repo); + expect(pull.code, pull.output).toBe(0); + expect(gitOk(['config', '--get', key], repo)).toBe('false'); + const off = worktreeAdd(repo, `disabled-wt-${key}`); + expect(off.code, off.output).toBe(0); + expect(fs.existsSync(path.join(off.dir, '.claude'))).toBe(false); + + gitOk(['config', '--local', key, 'true'], repo); + expect(teamai(['doctor'], repo).output).toContain('✔ Git hook syncs new worktrees and git pull'); + const on = worktreeAdd(repo, `reactivated-wt-${key}`); + expect(on.code, on.output).toBe(0); + expect(hasSkill(on.dir, 'team-skill')).toBe(true); + expect(fs.existsSync(path.join(on.dir, '.claude', 'rules', 'team-rule.md'))).toBe(true); + await settle(repo); + }); + 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); @@ -577,8 +656,8 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc 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']); + it.each(['merge', 'rebase'])('a partition lock another pull holds: %s waits no longer than its cap and records why it skipped', async (mode) => { + const repo = project(`locked-${mode}-project`, ['--agent', 'claude']); const bare = `${repo}.git`; gitOk(['clone', '-q', '--bare', repo, bare], sandbox); gitOk(['remote', 'add', 'origin', bare], repo); @@ -590,13 +669,14 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc gitOk(['add', '-A'], mate); gitOk(['commit', '-q', '-m', 'change'], mate); gitOk(['push', '-q', 'origin', 'HEAD:main'], mate); + if (mode === 'rebase') gitOk(['commit', '-q', '--allow-empty', '-m', 'local change'], repo); 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 r = git(['pull', '-q', ...(mode === 'rebase' ? ['--rebase'] : [])], repo); const elapsed = Date.now() - started; expect(r.code, r.output).toBe(0); @@ -609,7 +689,8 @@ describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resourc } 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/); + const event = mode === 'merge' ? 'post-merge' : 'post-rewrite'; + expect(doctor).toContain(`✖ Last git hook run failed: ${event} skipped its sync: another teamai process held the project's sync lock`); }, 60_000); it('a successful hook run clears the recorded failure', async () => { diff --git a/src/__tests__/git-hook.test.ts b/src/__tests__/git-hook.test.ts index 890665d66..29bded98b 100644 --- a/src/__tests__/git-hook.test.ts +++ b/src/__tests__/git-hook.test.ts @@ -13,6 +13,7 @@ const gitVersion = (): [number, number] => { }; const [major, minor] = gitVersion(); const configHooks = major > 2 || (major === 2 && minor >= 54); +const eventDisable = major > 2 || (major === 2 && minor >= 55); const GIT_ENV = { GIT_AUTHOR_NAME: 'TeamAI CI', @@ -63,6 +64,7 @@ describe.skipIf(!configHooks)('teamai git hook in the repository config', () => expect(git(['hook', 'list', 'post-checkout']).stdout.trim()).toBe('teamai-post-checkout'); expect(git(['hook', 'list', 'post-merge']).stdout.trim()).toBe('teamai-post-merge'); + expect(git(['hook', 'list', 'post-rewrite']).stdout.trim()).toBe('teamai-post-rewrite'); // 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'); @@ -76,7 +78,7 @@ describe.skipIf(!configHooks)('teamai git hook in the repository config', () => await installGitHook(repo); const planned = await removeGitHook(repo, { dryRun: true }); - expect(planned).toEqual(['hook.teamai-post-checkout', 'hook.teamai-post-merge']); + expect(planned).toEqual(['hook.teamai-post-checkout', 'hook.teamai-post-merge', 'hook.teamai-post-rewrite']); expect(git(['config', '--get-regexp', '^hook\\.teamai']).stdout).not.toBe(''); expect(await removeGitHook(repo)).toEqual(planned); @@ -92,6 +94,70 @@ describe.skipIf(!configHooks)('teamai git hook in the repository config', () => expect(git(['config', '--local', '--get-all', 'hook.teamai-post-merge.event']).stdout.trim()).toBe('post-merge'); }); + it('reports a named hook disabled, preserves the setting on pull and recognizes reactivation', async () => { + await installGitHook(repo); + git(['config', '--local', 'hook.teamai-post-checkout.enabled', 'false']); + + expect(await gitHookStatus(repo)).toEqual({ + installed: false, reason: 'disabled', event: 'post-checkout', disabledBy: 'hook', scope: 'local', + }); + await installGitHook(repo); + expect(git(['config', '--get', 'hook.teamai-post-checkout.enabled']).stdout.trim()).toBe('false'); + git(['config', '--local', 'hook.teamai-post-checkout.enabled', 'true']); + expect(await gitHookStatus(repo)).toEqual({ installed: true }); + fakeTeamai(0); + expect(git(['hook', 'run', 'post-checkout', '--', 'old', 'new', '1']).status).toBe(0); + expect(calls()).toEqual(['hook-dispatch post-checkout --tool git old new 1']); + }); + + it.each([ + ['--local', 'hook'], ['--local', 'event'], ['--global', 'hook'], ['--global', 'event'], + ].filter(([, disabledBy]) => disabledBy !== 'event' || eventDisable))('respects effective %s %s disable settings for post-rewrite', async (scope, disabledBy) => { + vi.stubEnv('GIT_CONFIG_GLOBAL', path.join(home, '.gitconfig')); + try { + await installGitHook(repo); + const key = `hook.${disabledBy === 'hook' ? 'teamai-post-rewrite' : 'post-rewrite'}.enabled`; + expect(git(['config', scope, key, 'false']).status).toBe(0); + const status = await gitHookStatus(repo); + expect(status).toEqual({ installed: false, reason: 'disabled', event: 'post-rewrite', disabledBy, scope: scope.slice(2) }); + if (!status.installed) { + const advice = describeMissingGitHook(status); + expect(advice).toContain(`${key}=false`); + expect(advice).toContain(`git config --local ${key} true`); + } + await installGitHook(repo); + expect(git(['config', '--get', key]).stdout.trim()).toBe('false'); + // The local override must be judged by Git, even when global config disables it. + expect(git(['config', '--local', key, 'true']).status).toBe(0); + expect(await gitHookStatus(repo)).toEqual({ installed: true }); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('advises unsetting a disable set in per-worktree config, which --local cannot override', async () => { + await installGitHook(repo); + git(['config', 'extensions.worktreeConfig', 'true']); + const key = 'hook.teamai-post-checkout.enabled'; + expect(git(['config', '--worktree', key, 'false']).status).toBe(0); + const status = await gitHookStatus(repo); + expect(status).toEqual({ installed: false, reason: 'disabled', event: 'post-checkout', disabledBy: 'hook', scope: 'worktree' }); + const advice = describeMissingGitHook(status as Exclude); + expect(advice).toContain(`Run \`git config --worktree --unset ${key}\``); + expect(advice).not.toContain('git config --local'); + expect(advice).toContain('Teamai pull preserves explicit hook disable settings.'); + expect(git(['config', '--worktree', '--unset', key]).status).toBe(0); + expect(await gitHookStatus(repo)).toEqual({ installed: true }); + }); + + it('names a scope teamai cannot point at, and asks to unset or override it there', () => { + const advice = describeMissingGitHook({ installed: false, reason: 'disabled', event: 'post-merge', disabledBy: 'hook', scope: 'command' }); + expect(advice).toContain('hook.teamai-post-merge.enabled=false in command config'); + expect(advice).toContain('Unset or override it there'); + expect(advice).not.toContain('git config --local'); + expect(advice).toContain('Teamai pull preserves explicit hook disable settings.'); + }); + it('passes the event and Git\'s arguments to the dispatcher, silently', async () => { await installGitHook(repo); fakeTeamai(0); @@ -179,21 +245,25 @@ describe('teamai hook script on a Git without config hooks', () => { ); }; - it('adds a marked block to an existing hook script without changing its other lines', async () => { + it.each(['post-checkout', 'post-rewrite'])('adds a marked block to an existing %s script without changing its other lines', async (event) => { const original = '#!/bin/sh\n# the team\'s own hook\necho mine >> "$0.log"\n'; - fs.writeFileSync(hookFile('post-checkout'), original, { mode: 0o755 }); + fs.writeFileSync(hookFile(event), original, { mode: 0o755 }); expect(await installGitHook(repo)).toEqual({ installed: true, changed: true }); - const text = fs.readFileSync(hookFile('post-checkout'), 'utf8'); + const text = fs.readFileSync(hookFile(event), '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); + expect(fs.readFileSync(hookFile(event), 'utf8')).toBe(text); // post-merge had no script: a new executable one. expect(fs.statSync(hookFile('post-merge')).mode & 0o111).not.toBe(0); + expect(fs.statSync(hookFile('post-rewrite')).mode & 0o111).not.toBe(0); expect(await gitHookStatus(repo)).toEqual({ installed: true }); + await removeGitHook(repo); + expect(fs.readFileSync(hookFile(event), 'utf8')).toBe(original); + expect(fs.existsSync(hookFile('post-merge'))).toBe(false); }); it('runs the dispatcher with Git\'s arguments, silently, keeping the script\'s own work and exit status', async () => { @@ -290,7 +360,7 @@ describe('teamai hook script on a Git without config hooks', () => { await installGitHook(repo); const planned = await removeGitHook(repo, { dryRun: true }); - expect(planned).toHaveLength(2); + expect(planned).toEqual([hookFile('post-checkout'), hookFile('post-merge'), hookFile('post-rewrite')]); expect(fs.readFileSync(hookFile('post-checkout'), 'utf8')).not.toBe(original); expect(await removeGitHook(repo)).toEqual(planned); @@ -308,6 +378,7 @@ describe('teamai hook script on a Git without config hooks', () => { 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(fs.existsSync(hookFile('post-rewrite'))).toBe(false); expect(run(['hook', 'list', 'post-checkout']).stdout).toContain('teamai-post-checkout'); }); diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 7d1d69f9f..26f47e7c4 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -244,6 +244,96 @@ describe('hook-handlers registry', () => { expect(mockPull).toHaveBeenCalledWith({ silent: true, inline: true, gitHook: 'post-merge', fetchTimeoutMs: 5000 }); }); + it.each(['rebase', 'amend'])('post-rewrite syncs only a completed rebase, not amend (%s)', async (rewrite) => { + const { createDispatcher } = await import('../hook-dispatch.js'); + const dispatcher = createDispatcher({ + handlers: buildHandlerRegistry(), + localConfig: { + scope: 'project', projectRoot: '/tmp/self-project', username: 'test', additionalRoles: [], + repo: { kind: 'self', localPath: '/tmp/self-project', remote: '' }, + }, + }); + const result = await dispatcher.dispatch('post-rewrite', '*', { + cwd: '/tmp/self-project', hook_event_name: 'post-rewrite', git_args: [rewrite], + }, 'git'); + expect(result.errors).toEqual([]); + expect(result.output).toBeNull(); + if (rewrite === 'amend') expect(mockPull).not.toHaveBeenCalled(); + else expect(mockPull).toHaveBeenCalledWith({ silent: true, inline: true, gitHook: 'post-rewrite', fetchTimeoutMs: 5000 }); + }); + + // Git 2.14–2.32 runs a fast-forward `git pull --rebase` with autostash as a + // rebase that only checks out the upstream: no post-merge, no post-rewrite. + describe('post-checkout from a rebase that fast-forwards', () => { + let repo: string; + let base: string; + let ahead: string; + let side: string; + const savedAction = process.env.GIT_REFLOG_ACTION; + + beforeAll(async () => { + const { execFileSync } = await import('node:child_process'); + repo = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'git-hook-ff-rebase-'))); + const git = (args: string[]) => execFileSync('git', args, { + cwd: repo, encoding: 'utf8', + env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }, + }).trim(); + git(['init', '-q', '-b', 'main']); + git(['commit', '-q', '--allow-empty', '-m', 'base']); + base = git(['rev-parse', 'HEAD']); + git(['commit', '-q', '--allow-empty', '-m', 'upstream']); + ahead = git(['rev-parse', 'HEAD']); + git(['checkout', '-q', '-b', 'local', base]); + git(['commit', '-q', '--allow-empty', '-m', 'local']); + side = git(['rev-parse', 'HEAD']); + }); + afterEach(() => { + if (savedAction === undefined) delete process.env.GIT_REFLOG_ACTION; + else process.env.GIT_REFLOG_ACTION = savedAction; + }); + afterAll(() => fs.rmSync(repo, { recursive: true, force: true })); + + const checkout = async (args: () => string[], action: string | undefined) => { + if (action === undefined) delete process.env.GIT_REFLOG_ACTION; + else process.env.GIT_REFLOG_ACTION = action; + const { createDispatcher } = await import('../hook-dispatch.js'); + const dispatcher = createDispatcher({ + handlers: buildHandlerRegistry(), + localConfig: { + scope: 'project', projectRoot: repo, username: 'test', additionalRoles: [], + repo: { kind: 'self', localPath: repo, remote: '' }, + }, + }); + const result = await dispatcher.dispatch('post-checkout', '*', { + cwd: repo, hook_event_name: 'post-checkout', git_args: args(), + }, 'git'); + expect(result.errors).toEqual([]); + }; + + it.each([ + 'pull --rebase --autostash (start): checkout origin/main', + 'pull -q --rebase --autostash', + 'rebase (start): checkout origin/main', + 'rebase', + ])('syncs once, as a capped git pull recorded as post-checkout (GIT_REFLOG_ACTION=%s)', async (action) => { + await checkout(() => [base, ahead, '1'], action); + expect(mockPull).toHaveBeenCalledTimes(1); + expect(mockPull).toHaveBeenCalledWith({ silent: true, inline: true, gitHook: 'post-checkout', fetchTimeoutMs: 5000 }); + }); + + it.each<[string, () => string[], string | undefined]>([ + ['a divergent rebase (post-rewrite syncs it)', () => [side, ahead, '1'], 'pull --rebase'], + ['a checkout outside pull or rebase', () => [base, ahead, '1'], undefined], + ['an unknown reflog action', () => [base, ahead, '1'], 'pullish'], + ['a file checkout', () => [base, ahead, '0'], 'pull --rebase'], + ['an unchanged HEAD', () => [ahead, ahead, '1'], 'pull --rebase'], + ['arguments that are not object ids', () => ['--help', ahead, '1'], 'pull --rebase'], + ])('does not sync %s', async (_case, args, action) => { + await checkout(args, action); + expect(mockPull).not.toHaveBeenCalled(); + }); + }); + 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-'))); @@ -266,6 +356,7 @@ describe('hook-handlers registry', () => { 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); + await registry.find(r => r.event === 'post-rewrite')!.handler.execute({ cwd: wt, hook_event_name: 'post-rewrite', git_args: ['rebase'] }, 'git', config); expect(mockPull).not.toHaveBeenCalled(); } finally { fs.rmSync(main, { recursive: true, force: true }); diff --git a/src/git-hook.ts b/src/git-hook.ts index ebe92178c..38612b539 100644 --- a/src/git-hook.ts +++ b/src/git-hook.ts @@ -1,8 +1,8 @@ /** * 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. + * `teamai hook-dispatch --tool git` on `post-checkout`, `post-merge` + * and `post-rewrite`, so a new worktree gets the team's resources before + * `git worktree add` returns, and `git pull` before it returns. * * Config hooks (`hook..command` + `hook..event`, Git >= 2.54) live * in the common config every worktree shares, and run beside `core.hooksPath` @@ -29,7 +29,7 @@ import { execCommand } from './utils/exec.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; +export const GIT_HOOK_EVENTS = ['post-checkout', 'post-merge', 'post-rewrite'] as const; export type GitHookEvent = (typeof GIT_HOOK_EVENTS)[number]; /** The `--tool` value of a dispatch git runs. */ @@ -50,8 +50,8 @@ export function gitHookCommand(event: GitHookEvent): string { } /** - * 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 + * The line a team may commit into its own hook manager's Git hooks when + * teamai cannot install its hook: a no-op that exits 0 * on a machine without teamai. */ export function guardedGitHookLine(event: GitHookEvent): string { @@ -88,6 +88,7 @@ export type GitHookInstall = export type GitHookStatus = | { installed: true } + | { installed: false; reason: 'disabled'; event: GitHookEvent; disabledBy: 'hook' | 'event'; scope: string } | { installed: false; reason: 'hooks-path' | 'other-hook'; gitVersion: string } | { installed: false; reason: 'not-a-repository' | 'not-configured' }; @@ -115,25 +116,55 @@ export async function gitHookStatus(repoDir: string): Promise { 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' }; + if ((await eventsToWrite(git)).length > 0) return { installed: false, reason: 'not-configured' }; + for (const event of GIT_HOOK_EVENTS) { + const { code, stdout, stderr } = await git(['hook', 'list', event]); + if (code !== 0) throw new Error(`Could not inspect the ${event} hook: ${stderr.trim() || `exit ${code}`}`); + const entries = stdout.trim().split('\n'); + const disabledBy = entries.includes(`event-disabled\t${hookName(event)}`) ? 'event' + : entries.includes(`disabled\t${hookName(event)}`) ? 'hook' : null; + if (disabledBy) { + // Where the effective `false` lives decides how to undo it: `--local` cannot override worktree config. + const key = `hook.${disabledBy === 'event' ? event : hookName(event)}.enabled`; + const scope = (await git(['config', '--show-scope', '--get', key])).stdout.split('\t')[0].trim(); + return { installed: false, reason: 'disabled', event, disabledBy, scope }; + } + } + return { installed: true }; } /** 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 'disabled': { + const key = `hook.${status.disabledBy === 'event' ? status.event : hookName(status.event)}.enabled`; + const preserved = 'Teamai pull preserves explicit hook disable settings.'; + if (status.scope === 'worktree') { + return `Git disables ${hookName(status.event)} through ${key}=false in this worktree's config, so it will not sync resources. ` + + `Run \`git config --worktree --unset ${key}\` to enable it, then \`teamai pull\` to sync. ${preserved}`; + } + // Local config overrides global and system; an unreadable scope gets the same advice. + if (!status.scope || ['local', 'global', 'system'].includes(status.scope)) { + return `Git disables ${hookName(status.event)} through ${key}=false, so it will not sync resources. ` + + `Run \`git config --local ${key} true\` to enable it for this repository, then \`teamai pull\` to sync. ${preserved}`; + } + return `Git disables ${hookName(status.event)} through ${key}=false in ${status.scope} config, so it will not sync resources. ` + + `Unset or override it there, then run \`teamai pull\` to sync. ${preserved}`; + } 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 a symlink or not an executable shell script, so teamai leaves it alone'; + : 'a post-checkout, post-merge or post-rewrite hook in .git/hooks is a symlink or not an executable 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.'; + + `\`${guardedGitHookLine('post-merge')}\`, and this one from the post-rewrite hook, ` + + `\`${guardedGitHookLine('post-rewrite')}\`; wrap each in \`sh -c '...'\` when the hook config is not a ` + + 'shell script. All 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.'; @@ -207,7 +238,7 @@ async function installHookScripts(git: Git, repoDir: string, opts: { dryRun?: bo } /** - * Take teamai's block out of the post-checkout and post-merge scripts in the + * Take teamai's block out of the post-checkout, post-merge and post-rewrite 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. @@ -290,6 +321,28 @@ export function isNewCheckout(args: readonly string[]): boolean { return !!oldRef && ZERO_OID.test(oldRef) && branchFlag === '1'; } +const OID = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/; + +/** + * Whether a `post-checkout` is a pull or rebase that fast-forwarded HEAD. Git + * 2.14–2.32 runs a fast-forward `git pull --rebase` with autostash as a rebase + * that only checks out the upstream, so neither post-merge nor post-rewrite + * runs. A divergent rebase checks out first too, but its old HEAD is no + * ancestor of the new one: it syncs on post-rewrite. GIT_REFLOG_ACTION names + * the command; any other action is not one. + */ +export async function isRebaseFastForward( + args: readonly string[], + reflogAction: string | undefined, + cwd: string, +): Promise { + const [oldRef, newRef, branchFlag] = args; + if (branchFlag !== '1' || !oldRef || !newRef || oldRef === newRef) return false; + if (![oldRef, newRef].every(ref => OID.test(ref) && !ZERO_OID.test(ref))) return false; + if (!/^(?:pull|rebase)(?:\s|$)/.test(reflogAction ?? '')) return false; + return (await gitIn(cwd)(['merge-base', '--is-ancestor', oldRef, newRef])).code === 0; +} + /** * Variables through which Git hands a hook the repository it runs for * (`git rev-parse --local-env-vars`, minus GIT_CONFIG_COUNT and its diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index ca0f7d262..6300b6d91 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -13,6 +13,7 @@ import path from 'node:path'; import type { HookHandler } from './hook-dispatch.js'; import type { LocalConfig } from './types.js'; +import type { GitHookEvent } from './git-hook.js'; import { deriveDispatchSessionId, deriveSessionId } from './utils/session-id.js'; import { log } from './utils/logger.js'; import { normalizeToolName } from './utils/tool-names.js'; @@ -170,13 +171,14 @@ const newWorktreeHandler: HookHandler = { }, }; -/** How long `git pull` waits for the post-merge hook's team repo fetch. */ -const POST_MERGE_FETCH_CAP_MS = 5_000; +/** How long `git pull` waits for its hook's team repo fetch. */ +const GIT_PULL_FETCH_CAP_MS = 5_000; /** - * `post-merge` from the git hook (`git pull`): the next session gets what + * `post-merge`, `post-rewrite` after a rebase, or `post-checkout` from a rebase + * that only fast-forwarded (`git pull`, see isRebaseFastForward): 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 + * GIT_PULL_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. @@ -185,7 +187,15 @@ const gitPullHandler: HookHandler = { name: 'git-pull', async execute(stdin, _tool, config) { if (!config || config.scope !== 'project') return null; + const event = stdin.hook_event_name === 'post-rewrite' || stdin.hook_event_name === 'post-checkout' + ? stdin.hook_event_name : 'post-merge'; + const args = Array.isArray(stdin.git_args) ? stdin.git_args.map(String) : []; + if (event === 'post-rewrite' && args[0] !== 'rebase') return null; const cwd = resolveHookCwd(stdin) ?? process.cwd(); + if (event === 'post-checkout') { + const { isRebaseFastForward } = await import('./git-hook.js'); + if (!await isRebaseFastForward(args, process.env.GIT_REFLOG_ACTION, cwd)) return null; + } 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. @@ -193,10 +203,10 @@ const gitPullHandler: HookHandler = { if (await isWithin(cwd, self ? own : [...own, config.repo.localPath])) return null; const { pull } = await import('./pull.js'); - await recordingFailure(config, 'post-merge', () => pull({ - silent: true, inline: true, gitHook: 'post-merge', fetchTimeoutMs: POST_MERGE_FETCH_CAP_MS, + await recordingFailure(config, event, () => pull({ + silent: true, inline: true, gitHook: event, fetchTimeoutMs: GIT_PULL_FETCH_CAP_MS, })); - if (!self) await spawnDetachedPull(cwd, 'post-merge'); + if (!self) await spawnDetachedPull(cwd, event); return null; }, }; @@ -207,7 +217,7 @@ const gitPullHandler: HookHandler = { */ async function recordingFailure( config: LocalConfig, - event: 'post-checkout' | 'post-merge', + event: GitHookEvent, pass: () => Promise, ): Promise { try { @@ -223,7 +233,7 @@ async function recordingFailure( * 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 { +async function spawnDetachedPull(cwd: string, event: GitHookEvent): Promise { const { resolveCliEntry } = await import('./builtin-hooks.js'); const { spawn } = await import('node:child_process'); spawn(process.execPath, [resolveCliEntry() ?? '', 'pull', '--silent'], { @@ -1106,10 +1116,12 @@ 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 - // caps its own fetch. + // has no hook timeout, so the budget is the detached pull's; git-pull hooks + // cap their own fetch. { event: 'post-checkout', matcher: '*', handler: newWorktreeHandler, timeoutMs: PULL_TIMEOUT_MS, requiresConfig: true }, + { event: 'post-checkout', matcher: '*', handler: gitPullHandler, timeoutMs: PULL_TIMEOUT_MS, requiresConfig: true }, { event: 'post-merge', matcher: '*', handler: gitPullHandler, timeoutMs: PULL_TIMEOUT_MS, requiresConfig: true }, + { event: 'post-rewrite', matcher: '*', handler: gitPullHandler, timeoutMs: PULL_TIMEOUT_MS, requiresConfig: true }, ]; } diff --git a/src/hooks.ts b/src/hooks.ts index aa4dd69b0..8c0c87967 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -2393,7 +2393,7 @@ async function installProjectGitHook(localConfig: LocalConfig, opts: { dryRun?: + '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}`); + log.info(`Would install or update the teamai git hook (post-checkout, post-merge, post-rewrite) in ${localConfig.projectRoot}`); } if (!result.installed) log.debug(`git hook: not installed in ${localConfig.projectRoot} (${result.reason})`); } diff --git a/src/pull.ts b/src/pull.ts index 884eacc74..e29e3538d 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -169,7 +169,7 @@ async function refreshTeamRepo( const version = await getHeadRev(localConfig.repo.localPath).catch(() => null); return { label: 'fetched within the TTL, not refetched', version, submodulesFailed: false, submodulesChanged: false }; } - // The post-merge hook caps the fetch: git pull is waiting on it. + // The git-pull 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) { @@ -2360,7 +2360,7 @@ 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. - // Post-merge caps it like its fetch: `git pull` waits on it, and the holder + // Git-pull hooks cap it like their fetch: `git pull` waits on them, 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; diff --git a/src/types.ts b/src/types.ts index b5938a50e..7bbce11ea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1126,7 +1126,7 @@ export interface GlobalOptions { */ inline?: boolean; /** - * Internal (the post-merge git hook, with `inline`): fetch the team repo + * Internal (post-merge/post-rewrite git hooks, 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. */ @@ -1136,7 +1136,7 @@ export interface GlobalOptions { * 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'; + gitHook?: 'post-checkout' | 'post-merge' | 'post-rewrite'; /** * Internal (`init --scope user`, `pull` only): pull the user scope even when * the current directory is a project-scoped checkout. diff --git a/src/uninstall.ts b/src/uninstall.ts index efbc57b98..66791c1f9 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1216,8 +1216,8 @@ async function executeRemoval(plan: RemovalPlan): Promise>> teamai git hook` block in .git/hooks/post-checkout and post-merge.'); + + 'Remove it yourself: `git config --local --remove-section hook.teamai-post-checkout` (and hook.teamai-post-merge and hook.teamai-post-rewrite), ' + + 'and the `# >>> teamai git hook` block in .git/hooks/post-checkout, post-merge and post-rewrite.'); } }