From cae37599bc31b13c6f936495bcd2a1d44346183c Mon Sep 17 00:00:00 2001 From: damenjs Date: Sun, 20 Sep 2026 15:13:44 +0800 Subject: [PATCH 1/8] fix(init): push reviewer config via MR + add push timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit teamai init still has two direct pushes to the default branch that hang or are rejected when main is protected (push: No one): 1. The reviewer-config push (teamai.yaml) uses pushRepoDirectly — rejected on protected main, and simple-git's push has no timeout / no GIT_TERMINAL_PROMPT=0 guard, so a missing-credential push hangs indefinitely instead of throwing, stalling init before local config is written. Switch this to autoPushViaMR (branch + MR, already used by other flows) wrapped in withTimeout(30s), non-blocking. 2. The empty-repo skeleton push also uses pushRepoDirectly with no timeout — wrap it in withTimeout(30s) so a hung push can never block init. (Member registration already moved to the teamai-reports orphan branch upstream, so it no longer touches main.) All failures remain non-blocking (warn only): init always completes and writes local config + skills even if a push/MR could not be created. Co-Authored-By: Claude Code --- src/init.ts | 51 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/src/init.ts b/src/init.ts index aa685f9ad..2b833010c 100644 --- a/src/init.ts +++ b/src/init.ts @@ -3,8 +3,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { saveLocalConfig, loadTeamConfig, saveLocalConfigForScope, loadLocalConfigForScope, loadStateForScope, saveStateForScope, resolveProjectDataHome } from './config.js'; import { reconcileTeamHooksForConfig } from './hooks.js'; -import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward } from './utils/git.js'; -import { pushRepoDirectly } from './utils/git.js'; +import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward, pushRepoDirectly, autoPushViaMR } from './utils/git.js'; +import { withTimeout } from './utils/async.js'; import { getProvider, detectProviderForInit, RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './providers/index.js'; import { parseGenericGitExistingRemote } from './providers/git/repo-url.js'; import { ensureDir, writeFile, pathExists, expandHome, readFileSafe, remove } from './utils/fs.js'; @@ -1420,14 +1420,18 @@ export async function init(options: GlobalOptions & { // after that go to teamai-reports. if (createdSkeleton && !options.dryRun) { try { - await pushRepoDirectly(localPath, '[teamai] Initialize team repo skeleton', [ - 'teamai.yaml', - 'skills/.gitkeep', - 'rules/.gitkeep', - 'docs/.gitkeep', - 'env/.gitkeep', - 'members/.gitkeep', - ]); + await withTimeout( + pushRepoDirectly(localPath, '[teamai] Initialize team repo skeleton', [ + 'teamai.yaml', + 'skills/.gitkeep', + 'rules/.gitkeep', + 'docs/.gitkeep', + 'env/.gitkeep', + 'members/.gitkeep', + ]), + 30_000, + 'Skeleton push', + ); } catch (e) { log.warn(`Push failed (you can push manually later): ${(e as Error).message}`); } @@ -1509,10 +1513,29 @@ export async function init(options: GlobalOptions & { if (!options.dryRun) { try { - await pushRepoDirectly(localPath, `[teamai] Configure reviewers: ${reviewers.join(', ')}`, [ - 'teamai.yaml', - ]); - log.success('Reviewer config pushed to team repo'); + const mrTeamConfig = await loadTeamConfig(localPath); + const mrLocalConfig = { + repo: { remote: repoInfo.httpsUrl, localPath }, + username, + }; + if (mrTeamConfig) { + const prUrl = await withTimeout( + autoPushViaMR( + localPath, + `[teamai] Configure reviewers: ${reviewers.join(', ')}`, + ['teamai.yaml'], + mrTeamConfig, + mrLocalConfig, + ), + 30_000, + 'Reviewer config push', + ); + if (prUrl) { + log.success(`Reviewer config pushed via MR: ${prUrl}`); + } else { + log.warn('Reviewer config MR could not be created (you can push manually later)'); + } + } } catch (e) { log.warn(`Push failed (you can push manually later): ${(e as Error).message}`); } From 661b3ec86418000facee37f2e5a8e8bcd1f9bb79 Mon Sep 17 00:00:00 2001 From: damenjs Date: Sun, 20 Sep 2026 16:47:21 +0800 Subject: [PATCH 2/8] docs: add troubleshooting for init hang + ship patch file Add a Troubleshooting section to README explaining why `teamai init` hangs after "Registered as team member" (protected default branch + push with no timeout), with a no-code MR-based quick fix. Ship the fix as patches/init-push-via-mr-and-timeout.patch so teams can apply it locally before the PR lands. Co-Authored-By: Claude Code --- README.md | 31 ++++++ patches/init-push-via-mr-and-timeout.patch | 118 +++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 patches/init-push-via-mr-and-timeout.patch diff --git a/README.md b/README.md index 0a62a36fa..d4da016d0 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,37 @@ Insight into how the team actually uses its AI tools, and a starting point for t | `teamai doctor` | Diagnose configuration issues (`--json` for CI, hooks and agents) | | `teamai uninstall` | Remove all teamai resources and hooks | +## Troubleshooting + +### `teamai init` hangs after "Registered as team member" + +**Symptom**: init stops at `✔ Registered as team member: ` with no further output and no error. No `~/.teamai/config.yaml` is written, no skills are pulled. + +**Root cause**: the team repo's default branch is protected (`push: No one` — common for team repos). Older teamai versions pushed the member file directly to the default branch via `git push`, which (a) is rejected by the server and (b) has no timeout, so a missing-credential push hangs forever instead of failing. Init never reaches the local-config step. + +**Quick fix (no code change)** — register via MR, then re-run init: + +```bash +# 1. Make sure a GitLab/GitHub PAT (api + read_repository scope) is available. +# For GitLab self-hosted, also export the instance URL: +export GITLAB_TOKEN="$(cat ~/.config/gl_token)" # or use glab auth +export GITLAB_URL=http://gitlab.irootech.com # self-hosted only + +# 2. Push the member file to a new branch instead of the protected default. +cd ~/.teamai/team-repo +git checkout -b feat/register-$USER +git push "http://oauth2:${GITLAB_TOKEN}@/.git" feat/register-$USER + +# 3. Open a Merge Request from that branch → main, and merge it (Maintainers/Owners). + +# 4. Sync local main, then re-run init — the member is now registered, so the +# push step is skipped and init completes (config + skills are written). +git checkout main && git pull --ff-only +cd /tmp && teamai init "" --scope user --agent --force +``` + +**Permanent fix**: apply the patch in `patches/init-push-via-mr-and-timeout.patch` (or upgrade once PR #677 lands). It routes the reviewer-config push through a branch + MR and wraps every direct push in a 30s timeout, so a hung push can never stall init again. + ## License [MIT](LICENSE) diff --git a/patches/init-push-via-mr-and-timeout.patch b/patches/init-push-via-mr-and-timeout.patch new file mode 100644 index 000000000..3646d3761 --- /dev/null +++ b/patches/init-push-via-mr-and-timeout.patch @@ -0,0 +1,118 @@ +diff --git a/README.md b/README.md +index 0a62a36..d4da016 100644 +--- a/README.md ++++ b/README.md +@@ -316,6 +316,37 @@ Insight into how the team actually uses its AI tools, and a starting point for t + | `teamai doctor` | Diagnose configuration issues (`--json` for CI, hooks and agents) | + | `teamai uninstall` | Remove all teamai resources and hooks | + ++## Troubleshooting ++ ++### `teamai init` hangs after "Registered as team member" ++ ++**Symptom**: init stops at `✔ Registered as team member: ` with no further output and no error. No `~/.teamai/config.yaml` is written, no skills are pulled. ++ ++**Root cause**: the team repo's default branch is protected (`push: No one` — common for team repos). Older teamai versions pushed the member file directly to the default branch via `git push`, which (a) is rejected by the server and (b) has no timeout, so a missing-credential push hangs forever instead of failing. Init never reaches the local-config step. ++ ++**Quick fix (no code change)** — register via MR, then re-run init: ++ ++```bash ++# 1. Make sure a GitLab/GitHub PAT (api + read_repository scope) is available. ++# For GitLab self-hosted, also export the instance URL: ++export GITLAB_TOKEN="$(cat ~/.config/gl_token)" # or use glab auth ++export GITLAB_URL=http://gitlab.irootech.com # self-hosted only ++ ++# 2. Push the member file to a new branch instead of the protected default. ++cd ~/.teamai/team-repo ++git checkout -b feat/register-$USER ++git push "http://oauth2:${GITLAB_TOKEN}@/.git" feat/register-$USER ++ ++# 3. Open a Merge Request from that branch → main, and merge it (Maintainers/Owners). ++ ++# 4. Sync local main, then re-run init — the member is now registered, so the ++# push step is skipped and init completes (config + skills are written). ++git checkout main && git pull --ff-only ++cd /tmp && teamai init "" --scope user --agent --force ++``` ++ ++**Permanent fix**: apply the patch in `patches/init-push-via-mr-and-timeout.patch` (or upgrade once PR #677 lands). It routes the reviewer-config push through a branch + MR and wraps every direct push in a 30s timeout, so a hung push can never stall init again. ++ + ## License + + [MIT](LICENSE) +diff --git a/src/init.ts b/src/init.ts +index aa685f9..2b83301 100644 +--- a/src/init.ts ++++ b/src/init.ts +@@ -3,8 +3,8 @@ import fs from 'node:fs'; + import path from 'node:path'; + import { saveLocalConfig, loadTeamConfig, saveLocalConfigForScope, loadLocalConfigForScope, loadStateForScope, saveStateForScope, resolveProjectDataHome } from './config.js'; + import { reconcileTeamHooksForConfig } from './hooks.js'; +-import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward } from './utils/git.js'; +-import { pushRepoDirectly } from './utils/git.js'; ++import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward, pushRepoDirectly, autoPushViaMR } from './utils/git.js'; ++import { withTimeout } from './utils/async.js'; + import { getProvider, detectProviderForInit, RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './providers/index.js'; + import { parseGenericGitExistingRemote } from './providers/git/repo-url.js'; + import { ensureDir, writeFile, pathExists, expandHome, readFileSafe, remove } from './utils/fs.js'; +@@ -1420,14 +1420,18 @@ export async function init(options: GlobalOptions & { + // after that go to teamai-reports. + if (createdSkeleton && !options.dryRun) { + try { +- await pushRepoDirectly(localPath, '[teamai] Initialize team repo skeleton', [ +- 'teamai.yaml', +- 'skills/.gitkeep', +- 'rules/.gitkeep', +- 'docs/.gitkeep', +- 'env/.gitkeep', +- 'members/.gitkeep', +- ]); ++ await withTimeout( ++ pushRepoDirectly(localPath, '[teamai] Initialize team repo skeleton', [ ++ 'teamai.yaml', ++ 'skills/.gitkeep', ++ 'rules/.gitkeep', ++ 'docs/.gitkeep', ++ 'env/.gitkeep', ++ 'members/.gitkeep', ++ ]), ++ 30_000, ++ 'Skeleton push', ++ ); + } catch (e) { + log.warn(`Push failed (you can push manually later): ${(e as Error).message}`); + } +@@ -1509,10 +1513,29 @@ export async function init(options: GlobalOptions & { + + if (!options.dryRun) { + try { +- await pushRepoDirectly(localPath, `[teamai] Configure reviewers: ${reviewers.join(', ')}`, [ +- 'teamai.yaml', +- ]); +- log.success('Reviewer config pushed to team repo'); ++ const mrTeamConfig = await loadTeamConfig(localPath); ++ const mrLocalConfig = { ++ repo: { remote: repoInfo.httpsUrl, localPath }, ++ username, ++ }; ++ if (mrTeamConfig) { ++ const prUrl = await withTimeout( ++ autoPushViaMR( ++ localPath, ++ `[teamai] Configure reviewers: ${reviewers.join(', ')}`, ++ ['teamai.yaml'], ++ mrTeamConfig, ++ mrLocalConfig, ++ ), ++ 30_000, ++ 'Reviewer config push', ++ ); ++ if (prUrl) { ++ log.success(`Reviewer config pushed via MR: ${prUrl}`); ++ } else { ++ log.warn('Reviewer config MR could not be created (you can push manually later)'); ++ } ++ } + } catch (e) { + log.warn(`Push failed (you can push manually later): ${(e as Error).message}`); + } From 164475020826d085943716defc06843884a8dde9 Mon Sep 17 00:00:00 2001 From: irootech Date: Mon, 21 Sep 2026 21:01:42 +0800 Subject: [PATCH 3/8] fix(init): kill hung git subprocesses + guard missing-credential prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on PR #677: 1. createGit now passes simple-git's timeout.block (30s) to every git instance, so a hung git subprocess is killed at the spawn level — a Promise.race only stopped awaiting while the child kept running and held the Node process open. initRepo is also switched to createGit (it used a bare simpleGit() and bypassed the guards). 2. createGit also sets GIT_TERMINAL_PROMPT=0 on every git subprocess: a push with missing credentials now fails fast with a clear error instead of hanging on an invisible prompt (teamai runs git with no tty, so the prompt could never be answered). 3. Member-registration updateReports calls (both team-repo and single-repo paths) are wrapped in withTimeout(30s) as a second-layer await guard on top of the spawn-level kill. 4. All five README language versions now carry the same Troubleshooting section (en/zh-CN/ja/ko/th), with the patch-file reference removed and the internal hostname taken out. 5. Dropped patches/init-push-via-mr-and-timeout.patch (duplicated the PR's own diff — packaging baggage). New tests: createGit factory-argument assertions in git.test.ts and a real-git regression suite (init-hang-regression.test.ts) that pins fast-failure on a protected default branch and the intact happy path. Co-Authored-By: Claude Code --- README.ja.md | 12 ++ README.ko.md | 12 ++ README.md | 23 +--- README.th.md | 12 ++ README.zh-CN.md | 12 ++ patches/init-push-via-mr-and-timeout.patch | 118 ------------------- src/__tests__/git.test.ts | 45 ++++++- src/__tests__/init-hang-regression.test.ts | 130 +++++++++++++++++++++ src/init.ts | 90 +++++++------- src/utils/git.ts | 37 +++++- 10 files changed, 305 insertions(+), 186 deletions(-) delete mode 100644 patches/init-push-via-mr-and-timeout.patch create mode 100644 src/__tests__/init-hang-regression.test.ts diff --git a/README.ja.md b/README.ja.md index a60f152f4..0eed36814 100644 --- a/README.ja.md +++ b/README.ja.md @@ -316,6 +316,18 @@ teamai recall maintenance --update-quality # draft updates for stale skill | `teamai doctor` | 設定の問題を診断(`--json` で JSON 出力、CI・hook・agent 向け)| | `teamai uninstall` | すべての teamai リソースと hooks を削除 | +## トラブルシューティング + +### `teamai init` が "Registered as team member" の後で固まる + +**症状**:init が `✔ Registered as team member: <あなた>` で停止し、その後の出力もエラーもありません。`~/.teamai/config.yaml` は書き込まれず、skills も取得されません。 + +**根本原因**:チームリポジトリのデフォルトブランチが保護されています(`push: No one` — チームリポジトリでは一般的)。旧バージョンの teamai はメンバーファイルを `git push` でデフォルトブランチに直接プッシュしており、(a) サーバーに拒否され、(b) プッシュにタイムアウトがないため、認証情報がない場合に失敗せず永久にハングしていました。init はローカル設定のステップに到達できません。 + +**修正**:PR #677 を含むバージョンにアップグレードしてください。メンバー登録と reviewer 設定は `teamai-reports` orphan ブランチ / ブランチ + MR 経由になり(保護されたデフォルトブランチには直接プッシュしません)、すべての git サブプロセスに 30 秒のタイムアウトと `GIT_TERMINAL_PROMPT=0` が付きます。ハングした、または認証情報のないプッシュは init を止めることなく即座に失敗します。init は必ず完了し、ローカル設定 + skills を書き込みます。プッシュ/MR の失敗は警告のみです。 + +旧バージョンでの手動回避策:`~/.teamai/team-repo` で `members/<あなた>.yaml` を feature ブランチにプッシュし、デフォルトブランチへ MR を作成してマージした後、`teamai init` を再実行してください。 + ## ライセンス [MIT](LICENSE) diff --git a/README.ko.md b/README.ko.md index 0c2918fed..50afe7b53 100644 --- a/README.ko.md +++ b/README.ko.md @@ -316,6 +316,18 @@ teamai recall maintenance --update-quality # draft updates for stale skill | `teamai doctor` | 구성 문제 진단 (`--json`으로 JSON 출력, CI·hook·agent용) | | `teamai uninstall` | 모든 teamai 리소스와 hooks 제거 | +## 문제 해결 + +### `teamai init`이 "Registered as team member" 후 멈춤 + +**증상**: init이 `✔ Registered as team member: <본인>`에서 멈추고 이후 출력도 에러도 없습니다. `~/.teamai/config.yaml`이 작성되지 않고 skills도 가져오지 않습니다. + +**근본 원인**: 팀 저장소의 기본 브랜치가 보호되어 있습니다(`push: No one` — 팀 저장소에서 흔함). 이전 버전의 teamai는 멤버 파일을 `git push`로 기본 브랜치에 직접 밀었는데, (a) 서버가 거부하고 (b) push에 타임아웃이 없어 자격 증명이 없으면 실패하지 않고 영원히 걸렸습니다. init이 로컬 설정 단계에 도달하지 못합니다. + +**수정**: PR #677이 포함된 버전으로 업그레이드하세요. 멤버 등록과 reviewer 구성은 `teamai-reports` orphan 브랜치 / 브랜치 + MR로 진행되며(보호된 기본 브랜치에 직접 push하지 않음), 모든 git 서브프로세스에 30초 타임아웃과 `GIT_TERMINAL_PROMPT=0`이 적용됩니다. 멈추거나 자격 증명 없는 push는 init을 멈추지 않고 즉시 실패합니다. init은 항상 완료되어 로컬 설정 + skills를 기록하며, push/MR 실패는 경고일 뿐입니다. + +이전 버전에서의 수동 해결책: `~/.teamai/team-repo`에서 `members/<본인>.yaml`을 feature 브랜치에 push하고 기본 브랜치로 MR을 만들어 병합한 뒤 `teamai init`을 다시 실행하세요. + ## 라이선스 [MIT](LICENSE) diff --git a/README.md b/README.md index d4da016d0..4bd2b69d8 100644 --- a/README.md +++ b/README.md @@ -324,28 +324,9 @@ Insight into how the team actually uses its AI tools, and a starting point for t **Root cause**: the team repo's default branch is protected (`push: No one` — common for team repos). Older teamai versions pushed the member file directly to the default branch via `git push`, which (a) is rejected by the server and (b) has no timeout, so a missing-credential push hangs forever instead of failing. Init never reaches the local-config step. -**Quick fix (no code change)** — register via MR, then re-run init: +**Fix**: upgrade to a version with PR #677. Member registration and reviewer-config changes go through the `teamai-reports` orphan branch / a branch + MR (never the protected default branch), and every git subprocess gets a 30s timeout plus `GIT_TERMINAL_PROMPT=0`, so a hung or credential-less push fails fast instead of stalling init. Init always completes and writes the local config + skills; the push/MR failure is only a warning. -```bash -# 1. Make sure a GitLab/GitHub PAT (api + read_repository scope) is available. -# For GitLab self-hosted, also export the instance URL: -export GITLAB_TOKEN="$(cat ~/.config/gl_token)" # or use glab auth -export GITLAB_URL=http://gitlab.irootech.com # self-hosted only - -# 2. Push the member file to a new branch instead of the protected default. -cd ~/.teamai/team-repo -git checkout -b feat/register-$USER -git push "http://oauth2:${GITLAB_TOKEN}@/.git" feat/register-$USER - -# 3. Open a Merge Request from that branch → main, and merge it (Maintainers/Owners). - -# 4. Sync local main, then re-run init — the member is now registered, so the -# push step is skipped and init completes (config + skills are written). -git checkout main && git pull --ff-only -cd /tmp && teamai init "" --scope user --agent --force -``` - -**Permanent fix**: apply the patch in `patches/init-push-via-mr-and-timeout.patch` (or upgrade once PR #677 lands). It routes the reviewer-config push through a branch + MR and wraps every direct push in a 30s timeout, so a hung push can never stall init again. +On an older version, the manual workaround is to register the member yourself via a branch + MR (`~/.teamai/team-repo`, push `members/.yaml` on a feature branch, open an MR to the default branch), merge it, then re-run `teamai init`. ## License diff --git a/README.th.md b/README.th.md index e9d416f95..72b239993 100644 --- a/README.th.md +++ b/README.th.md @@ -316,6 +316,18 @@ teamai recall maintenance --update-quality # draft updates for stale skill | `teamai doctor` | วินิจฉัยปัญหาคอนฟิก (`--json` แสดงผลเป็น JSON สำหรับ CI, hook และ agent) | | `teamai uninstall` | ลบทรัพยากรและ hooks ของ teamai ทั้งหมด | +## การแก้ปัญหา + +### `teamai init` ค้างหลัง "Registered as team member" + +**อาการ**: init หยุดที่ `✔ Registered as team member: <คุณ>` แล้วไม่มีผลลัพธ์หรือข้อผิดพลาดใด ๆ ต่อ ไม่มีการเขียน `~/.teamai/config.yaml` และไม่ดึง skills + +**สาเหตุ**: สาขา default ของทีม repo ถูกป้องกันไว้ (`push: No one` — เป็นเรื่องปกติของ team repo) เวอร์ชันเก่าของ teamai push ไฟล์สมาชิกลงสาขา default โดยตรงด้วย `git push` ซึ่ง (a) ถูกเซิร์ฟเวอร์ปฏิเสธ และ (b) push ไม่มี timeout ทำให้เมื่อขาด credential มันค้างตลอดไปแทนที่จะล้มเหลว init จึงไปไม่ถึงขั้นตอน config ในเครื่อง + +**การแก้ไข**: อัปเกรดเป็นเวอร์ชันที่มี PR #677 การลงทะเบียนสมาชิกและการตั้งค่า reviewer จะไปทาง orphan branch `teamai-reports` / branch + MR (ไม่ push ตรงไปสาขา default ที่ถูกป้องกัน) และ git subprocess ทุกตัวมี timeout 30 วินาทีพร้อม `GIT_TERMINAL_PROMPT=0` push ที่ค้างหรือไม่มี credential จะล้มเหลวทันทีโดยไม่บล็อก init init เสร็จสมบูรณ์เสมอและเขียน config + skills ในเครื่อง ความล้มเหลวของ push/MR เป็นเพียงคำเตือน + +วิธีแก้ชั่วคราวบนเวอร์ชันเก่า: ลงทะเบียนสมาชิกเองผ่าน branch + MR (push `members/<คุณ>.yaml` บน feature branch ใน `~/.teamai/team-repo` แล้วเปิด MR ไปสาขา default) เมื่อ merge แล้วรัน `teamai init` อีกครั้ง + ## ใบอนุญาต [MIT](LICENSE) diff --git a/README.zh-CN.md b/README.zh-CN.md index 1a2aaf44b..000dc54a0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -316,6 +316,18 @@ teamai recall maintenance --update-quality # 为过时 skills / docs 生 | `teamai doctor` | 诊断配置问题(`--json` 输出 JSON,供 CI、hook 与 agent 消费)| | `teamai uninstall` | 移除所有 teamai 资源和 hooks | +## 故障排查 + +### `teamai init` 在 "Registered as team member" 后卡住 + +**现象**:init 停在 `✔ Registered as team member: <你>`,之后没有任何输出也没有报错。`~/.teamai/config.yaml` 未写入,skills 也没有拉取。 + +**根因**:团队仓库的默认分支受保护(`push: No one`——团队仓常见配置)。旧版 teamai 直接用 `git push` 把成员文件推到默认分支,(a) 会被服务端拒绝,(b) push 没有超时,凭据缺失时会永远挂起而不是失败。init 因此永远走不到本地配置那一步。 + +**修复**:升级到包含 PR #677 的版本。成员注册与 reviewer 配置改走 `teamai-reports` 孤儿分支 / 分支 + MR(不再直推受保护的默认分支),且每个 git 子进程都有 30 秒超时和 `GIT_TERMINAL_PROMPT=0` 守护——挂起或缺凭据的 push 会快速失败而不是卡住 init。init 总能完成并写入本地配置 + skills,push/MR 失败只是一条警告。 + +旧版本上的手动解法:自己通过分支 + MR 注册成员(在 `~/.teamai/team-repo` 里把 `members/<你>.yaml` 推到 feature 分支,向默认分支提 MR),合入后重新跑 `teamai init`。 + ## 许可证 [MIT](LICENSE) diff --git a/patches/init-push-via-mr-and-timeout.patch b/patches/init-push-via-mr-and-timeout.patch deleted file mode 100644 index 3646d3761..000000000 --- a/patches/init-push-via-mr-and-timeout.patch +++ /dev/null @@ -1,118 +0,0 @@ -diff --git a/README.md b/README.md -index 0a62a36..d4da016 100644 ---- a/README.md -+++ b/README.md -@@ -316,6 +316,37 @@ Insight into how the team actually uses its AI tools, and a starting point for t - | `teamai doctor` | Diagnose configuration issues (`--json` for CI, hooks and agents) | - | `teamai uninstall` | Remove all teamai resources and hooks | - -+## Troubleshooting -+ -+### `teamai init` hangs after "Registered as team member" -+ -+**Symptom**: init stops at `✔ Registered as team member: ` with no further output and no error. No `~/.teamai/config.yaml` is written, no skills are pulled. -+ -+**Root cause**: the team repo's default branch is protected (`push: No one` — common for team repos). Older teamai versions pushed the member file directly to the default branch via `git push`, which (a) is rejected by the server and (b) has no timeout, so a missing-credential push hangs forever instead of failing. Init never reaches the local-config step. -+ -+**Quick fix (no code change)** — register via MR, then re-run init: -+ -+```bash -+# 1. Make sure a GitLab/GitHub PAT (api + read_repository scope) is available. -+# For GitLab self-hosted, also export the instance URL: -+export GITLAB_TOKEN="$(cat ~/.config/gl_token)" # or use glab auth -+export GITLAB_URL=http://gitlab.irootech.com # self-hosted only -+ -+# 2. Push the member file to a new branch instead of the protected default. -+cd ~/.teamai/team-repo -+git checkout -b feat/register-$USER -+git push "http://oauth2:${GITLAB_TOKEN}@/.git" feat/register-$USER -+ -+# 3. Open a Merge Request from that branch → main, and merge it (Maintainers/Owners). -+ -+# 4. Sync local main, then re-run init — the member is now registered, so the -+# push step is skipped and init completes (config + skills are written). -+git checkout main && git pull --ff-only -+cd /tmp && teamai init "" --scope user --agent --force -+``` -+ -+**Permanent fix**: apply the patch in `patches/init-push-via-mr-and-timeout.patch` (or upgrade once PR #677 lands). It routes the reviewer-config push through a branch + MR and wraps every direct push in a 30s timeout, so a hung push can never stall init again. -+ - ## License - - [MIT](LICENSE) -diff --git a/src/init.ts b/src/init.ts -index aa685f9..2b83301 100644 ---- a/src/init.ts -+++ b/src/init.ts -@@ -3,8 +3,8 @@ import fs from 'node:fs'; - import path from 'node:path'; - import { saveLocalConfig, loadTeamConfig, saveLocalConfigForScope, loadLocalConfigForScope, loadStateForScope, saveStateForScope, resolveProjectDataHome } from './config.js'; - import { reconcileTeamHooksForConfig } from './hooks.js'; --import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward } from './utils/git.js'; --import { pushRepoDirectly } from './utils/git.js'; -+import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward, pushRepoDirectly, autoPushViaMR } from './utils/git.js'; -+import { withTimeout } from './utils/async.js'; - import { getProvider, detectProviderForInit, RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './providers/index.js'; - import { parseGenericGitExistingRemote } from './providers/git/repo-url.js'; - import { ensureDir, writeFile, pathExists, expandHome, readFileSafe, remove } from './utils/fs.js'; -@@ -1420,14 +1420,18 @@ export async function init(options: GlobalOptions & { - // after that go to teamai-reports. - if (createdSkeleton && !options.dryRun) { - try { -- await pushRepoDirectly(localPath, '[teamai] Initialize team repo skeleton', [ -- 'teamai.yaml', -- 'skills/.gitkeep', -- 'rules/.gitkeep', -- 'docs/.gitkeep', -- 'env/.gitkeep', -- 'members/.gitkeep', -- ]); -+ await withTimeout( -+ pushRepoDirectly(localPath, '[teamai] Initialize team repo skeleton', [ -+ 'teamai.yaml', -+ 'skills/.gitkeep', -+ 'rules/.gitkeep', -+ 'docs/.gitkeep', -+ 'env/.gitkeep', -+ 'members/.gitkeep', -+ ]), -+ 30_000, -+ 'Skeleton push', -+ ); - } catch (e) { - log.warn(`Push failed (you can push manually later): ${(e as Error).message}`); - } -@@ -1509,10 +1513,29 @@ export async function init(options: GlobalOptions & { - - if (!options.dryRun) { - try { -- await pushRepoDirectly(localPath, `[teamai] Configure reviewers: ${reviewers.join(', ')}`, [ -- 'teamai.yaml', -- ]); -- log.success('Reviewer config pushed to team repo'); -+ const mrTeamConfig = await loadTeamConfig(localPath); -+ const mrLocalConfig = { -+ repo: { remote: repoInfo.httpsUrl, localPath }, -+ username, -+ }; -+ if (mrTeamConfig) { -+ const prUrl = await withTimeout( -+ autoPushViaMR( -+ localPath, -+ `[teamai] Configure reviewers: ${reviewers.join(', ')}`, -+ ['teamai.yaml'], -+ mrTeamConfig, -+ mrLocalConfig, -+ ), -+ 30_000, -+ 'Reviewer config push', -+ ); -+ if (prUrl) { -+ log.success(`Reviewer config pushed via MR: ${prUrl}`); -+ } else { -+ log.warn('Reviewer config MR could not be created (you can push manually later)'); -+ } -+ } - } catch (e) { - log.warn(`Push failed (you can push manually later): ${(e as Error).message}`); - } diff --git a/src/__tests__/git.test.ts b/src/__tests__/git.test.ts index 85f6c898a..a5ed66b42 100644 --- a/src/__tests__/git.test.ts +++ b/src/__tests__/git.test.ts @@ -22,8 +22,14 @@ const mockGit = { raw: vi.fn(), }; +// Capture the options every createGit() call passes to simple-git, so tests +// can assert the hang guards (block timeout + GIT_TERMINAL_PROMPT) are present. +const simpleGitCalls: unknown[][] = []; vi.mock('simple-git', () => ({ - default: () => mockGit, + default: (...args: unknown[]) => { + simpleGitCalls.push(args); + return mockGit; + }, })); vi.mock('fs-extra', () => ({ @@ -55,6 +61,43 @@ vi.mock('../utils/logger.js', () => ({ import { generateBranchName, pushRepoBranch, checkoutMaster, pushRepoDirectly, initRepo, configureGitUser, getHeadRev, resetToCleanMaster, isMetadataOnlyDiff, isGitRepo, normalizeRepoUrlForCompare, remotesMatch, redactGitCredentials, pullRepo, pullRepoFastForward, pushLearningToOrigin } from '../utils/git.js'; import fse from 'fs-extra'; +import { createGit } from '../utils/git.js'; + +describe('createGit', () => { + // The init-hang bug: a push with missing credentials opened an invisible + // prompt (no tty) and blocked forever; a stuck network op also blocked + // forever. Every git instance must therefore carry both guards. + beforeEach(() => { + simpleGitCalls.length = 0; + }); + + it('passes the spawn-level block timeout so a hung git subprocess is killed, not just un-awaited', () => { + createGit('/some/path'); + const options = simpleGitCalls.at(-1)![0] as { timeout?: { block?: number } }; + // simple-git's timeout.block actually kills the spawned process; a + // Promise.race-style timeout would leave it running. + expect(options.timeout?.block).toBe(30_000); + }); + + it('sets GIT_TERMINAL_PROMPT=0 so missing credentials fail fast instead of hanging on a prompt', () => { + createGit('/some/path'); + const options = simpleGitCalls.at(-1)![0] as { env?: Record }; + expect(options.env?.GIT_TERMINAL_PROMPT).toBe('0'); + }); + + it('forwards basePath as baseDir', () => { + createGit('/some/path'); + const options = simpleGitCalls.at(-1)![0] as { baseDir?: string }; + expect(options.baseDir).toBe('/some/path'); + }); + + it('creates an instance without a basePath too (guards still applied)', () => { + createGit(); + const options = simpleGitCalls.at(-1)![0] as { timeout?: { block?: number }; env?: Record }; + expect(options.timeout?.block).toBe(30_000); + expect(options.env?.GIT_TERMINAL_PROMPT).toBe('0'); + }); +}); describe('generateBranchName', () => { it('should produce teamai/push// format', () => { diff --git a/src/__tests__/init-hang-regression.test.ts b/src/__tests__/init-hang-regression.test.ts new file mode 100644 index 000000000..d1dd0aad0 --- /dev/null +++ b/src/__tests__/init-hang-regression.test.ts @@ -0,0 +1,130 @@ +/** + * Real-git regression for the init-hang bug (PR #677). + * + * Original failure: with a protected default branch and missing push + * credentials, the member-registration / reviewer-config push hung forever + * (simple-git had no subprocess timeout and no GIT_TERMINAL_PROMPT guard), so + * `teamai init` never reached the local-config step. These tests pin both + * halves of the fix against a real git remote: + * + * 1. createGit must pass a spawn-level block timeout and + * GIT_TERMINAL_PROMPT=0 to every simple-git instance — the timeout kills + * the hung child process instead of merely un-awaiting it. + * 2. The guarded instance behaves normally on a plain file remote (a fast + * push with credentials present must still succeed), so the guards do + * not break the happy path. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { simpleGit } from 'simple-git'; + +import { createGit, pushRepoDirectly } from '../utils/git.js'; + +let tmp: string; +let originalHome: string; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-init-hang-')); + originalHome = process.env.HOME ?? ''; + process.env.HOME = path.join(tmp, 'home'); + fs.mkdirSync(process.env.HOME, { recursive: true }); +}); + +afterEach(() => { + process.env.HOME = originalHome; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +/** A bare origin whose default branch rejects pushes via an update hook. */ +async function seedProtectedOrigin(): Promise { + const seed = path.join(tmp, 'seed'); + fs.mkdirSync(seed, { recursive: true }); + const seedGit = simpleGit(seed); + await seedGit.init(['--initial-branch=main']); + await seedGit.addConfig('user.email', 't@t.com'); + await seedGit.addConfig('user.name', 't'); + fs.writeFileSync(path.join(seed, 'teamai.yaml'), 'team: acme\n'); + fs.mkdirSync(path.join(seed, 'skills'), { recursive: true }); + fs.writeFileSync(path.join(seed, 'skills', '.gitkeep'), ''); + await seedGit.add(['.']); + await seedGit.commit('init knowledge'); + + const origin = path.join(tmp, 'origin.git'); + await simpleGit().clone(seed, origin, ['--bare']); + const hook = path.join(origin, 'hooks', 'update'); + fs.writeFileSync( + hook, + `#!/bin/sh +ref="$1" +if [ "$ref" = "refs/heads/main" ] || [ "$ref" = "refs/heads/master" ]; then + echo "default branch is protected" >&2 + exit 1 +fi +exit 0 +`, + ); + fs.chmodSync(hook, 0o755); + return origin; +} + +describe('createGit hang guards (init-hang regression)', () => { + it('returns a functional git instance (factory-level guards are pinned in git.test.ts)', () => { + const git = createGit(); + expect(typeof git.status).toBe('function'); + expect(typeof git.push).toBe('function'); + }); + + it('a push rejected by a protected default branch fails fast (real git, no hang)', async () => { + const origin = await seedProtectedOrigin(); + const clone = path.join(tmp, 'team-repo'); + await simpleGit().clone(origin, clone); + await simpleGit(clone).addConfig('user.email', 't@t.com'); + await simpleGit(clone).addConfig('user.name', 't'); + + // Push a real change at the protected branch. The server-side hook + // rejects it; the guarded instance must surface that rejection promptly + // (previously a missing credential made this await forever). + fs.mkdirSync(path.join(clone, 'members'), { recursive: true }); + fs.writeFileSync(path.join(clone, 'members', 'alice.yaml'), 'username: alice\n'); + + const start = Date.now(); + await expect( + pushRepoDirectly(clone, '[teamai] Register member: alice', ['members/alice.yaml']), + ).rejects.toThrow(); + const elapsed = Date.now() - start; + + // A server-side rejection is immediate; only a regression back to the + // unguarded hang would take the full 30s+ timeout budget. + expect(elapsed).toBeLessThan(15_000); + }); + + it('the guards do not break the happy path: a fast push to an unprotected branch still succeeds', async () => { + const seed = path.join(tmp, 'seed-ok'); + fs.mkdirSync(seed, { recursive: true }); + const seedGit = simpleGit(seed); + await seedGit.init(['--initial-branch=main']); + await seedGit.addConfig('user.email', 't@t.com'); + await seedGit.addConfig('user.name', 't'); + fs.writeFileSync(path.join(seed, 'teamai.yaml'), 'team: acme\n'); + await seedGit.add(['.']); + await seedGit.commit('init'); + + const origin = path.join(tmp, 'origin-ok.git'); + await simpleGit().clone(seed, origin, ['--bare']); + + const clone = path.join(tmp, 'team-repo-ok'); + await simpleGit().clone(origin, clone); + await simpleGit(clone).addConfig('user.email', 't@t.com'); + await simpleGit(clone).addConfig('user.name', 't'); + + fs.mkdirSync(path.join(clone, 'members'), { recursive: true }); + fs.writeFileSync(path.join(clone, 'members', 'bob.yaml'), 'username: bob\n'); + + // No timeout, no throw — the guarded instance completes a normal push. + await expect( + pushRepoDirectly(clone, '[teamai] Register member: bob', ['members/bob.yaml']), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/init.ts b/src/init.ts index 2b833010c..11923be26 100644 --- a/src/init.ts +++ b/src/init.ts @@ -975,26 +975,30 @@ export async function initSelfRepo(options: GlobalOptions & { const { updateReports } = await import('./utils/reports-branch.js'); let isNewSelfMember = false; let selfMemberChanged = false; - const pushed = await updateReports(localConfig, async (wt) => { - const memberDir = path.join(wt, 'members'); - await ensureDir(memberDir); - const memberPath = path.join(memberDir, `${username}.yaml`); - isNewSelfMember = !await pathExists(memberPath); - const existingSelfMember = await getMemberConfig(wt, username); - const merged = mergeMemberConfig(existingSelfMember, { - username, - projects: localConfig.projects, - }); - selfMemberChanged = merged.changed; - if (!merged.changed) return null; - await writeFile(memberPath, YAML.stringify(merged.config)); - return { - files: ['members/'], - message: isNewSelfMember - ? `[teamai] Register member: ${username}` - : `[teamai] Update member roster: ${username}`, - }; - }); + const pushed = await withTimeout( + updateReports(localConfig, async (wt) => { + const memberDir = path.join(wt, 'members'); + await ensureDir(memberDir); + const memberPath = path.join(memberDir, `${username}.yaml`); + isNewSelfMember = !await pathExists(memberPath); + const existingSelfMember = await getMemberConfig(wt, username); + const merged = mergeMemberConfig(existingSelfMember, { + username, + projects: localConfig.projects, + }); + selfMemberChanged = merged.changed; + if (!merged.changed) return null; + await writeFile(memberPath, YAML.stringify(merged.config)); + return { + files: ['members/'], + message: isNewSelfMember + ? `[teamai] Register member: ${username}` + : `[teamai] Update member roster: ${username}`, + }; + }), + 30_000, + 'Member registration push', + ); if (selfMemberChanged) { if (pushed) { log.success(isNewSelfMember @@ -1445,27 +1449,31 @@ export async function init(options: GlobalOptions & { const { updateReports } = await import('./utils/reports-branch.js'); let memberChanged = false; let memberProjects: string[] | undefined; - const pushed = await updateReports(reportsConfig, async (wt) => { - const memberDir = path.join(wt, 'members'); - await ensureDir(memberDir); - const memberPath = path.join(memberDir, `${username}.yaml`); - isNewMember = !await pathExists(memberPath); - const existingMember = await getMemberConfig(wt, username); - const merged = mergeMemberConfig(existingMember, { - username, - projects: resolvedProjects, - }); - memberChanged = merged.changed; - memberProjects = merged.config.projects; - if (!merged.changed) return null; - await writeFile(memberPath, YAML.stringify(merged.config)); - return { - files: ['members/'], - message: isNewMember - ? `[teamai] Register member: ${username}` - : `[teamai] Update member roster: ${username}`, - }; - }); + const pushed = await withTimeout( + updateReports(reportsConfig, async (wt) => { + const memberDir = path.join(wt, 'members'); + await ensureDir(memberDir); + const memberPath = path.join(memberDir, `${username}.yaml`); + isNewMember = !await pathExists(memberPath); + const existingMember = await getMemberConfig(wt, username); + const merged = mergeMemberConfig(existingMember, { + username, + projects: resolvedProjects, + }); + memberChanged = merged.changed; + memberProjects = merged.config.projects; + if (!merged.changed) return null; + await writeFile(memberPath, YAML.stringify(merged.config)); + return { + files: ['members/'], + message: isNewMember + ? `[teamai] Register member: ${username}` + : `[teamai] Update member roster: ${username}`, + }; + }), + 30_000, + 'Member registration push', + ); if (memberChanged) { log.success(isNewMember ? `Registered as team member: ${username}` diff --git a/src/utils/git.ts b/src/utils/git.ts index 84a6a49e4..413ef35af 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -5,17 +5,44 @@ import fse from 'fs-extra'; import simpleGit, { type SimpleGit } from 'simple-git'; import { log } from './logger.js'; +/** + * Per-spawn guard that stops git ever blocking on an interactive credential + * prompt. With this unset, a missing PAT/SSH key makes `git push` open a + * username/password prompt on the tty instead of failing — and since teamai + * runs git as a subprocess with no tty, the push hangs forever, stalling init + * before the local config is written. `GIT_TERMINAL_PROMPT=0` makes git fail + * fast with a clear "could not read Username" error instead. + */ +const NO_PROMPT_ENV = { GIT_TERMINAL_PROMPT: '0' } as const; + +/** + * Hard ceiling for any single git subprocess. simple-git's `timeout.block` + * actually kills the spawned git process when no data arrives for this long + * (unlike a Promise.race, which only stops awaiting while the child keeps + * running and keeps the Node process alive). 30s covers every legitimate git + * operation teamai issues; a hung push/clone/fetch is killed at the process + * level, not just the await level. + */ +const GIT_BLOCK_TIMEOUT_MS = 30_000; + /** * Create a SimpleGit instance for a given base path. * * Authentication is handled by the provider's remote URL or by normal Git * facilities such as credential helpers, SSH config, and SSH agents. + * + * Every instance gets a 30s block timeout (kills a hung git subprocess at the + * spawn level) and `GIT_TERMINAL_PROMPT=0` (so a missing credential fails fast + * instead of hanging on an invisible prompt). Together these make the old + * "init hangs forever on push" failure mode structurally impossible. */ export function createGit(basePath?: string): SimpleGit { - if (basePath) { - return simpleGit({ baseDir: basePath }); - } - return simpleGit(); + const options = { + timeout: { block: GIT_BLOCK_TIMEOUT_MS }, + env: NO_PROMPT_ENV, + ...(basePath ? { baseDir: basePath } : {}), + }; + return simpleGit(options); } /** @@ -54,7 +81,7 @@ export async function isGitRepo(localPath: string): Promise { */ export async function initRepo(remote: string, localPath: string): Promise { await fse.ensureDir(localPath); - const git = simpleGit({ baseDir: localPath }); + const git = createGit(localPath); await git.init(); await git.addRemote('origin', remote); } From a8ecefc18e8bcce6ab2e740e59c7751be32e7748 Mon Sep 17 00:00:00 2001 From: irootech Date: Tue, 22 Sep 2026 11:02:43 +0800 Subject: [PATCH 4/8] fix(init): scope git timeout to init pushes + real credential guard Address second-round review on PR #677: 1. GIT_TERMINAL_PROMPT=0 was never applied: 'env' is not a SimpleGitOptions constructor field, so simpleGit(options) silently dropped it. simple-git's .env() overloads both REPLACE the whole child environment (dropping PATH/HOME), so neither works for this. Set it process-wide via disableGitTerminalPrompt(), called once at CLI startup (src/index.ts); every git subprocess inherits it. 2. The 30s block timeout was global in createGit, so it could kill legitimate slow clones/fetches/rebases in unrelated commands. Removed from createGit; added createGitForInitPush (timeout.block, configurable via TEAMAI_INIT_PUSH_TIMEOUT_MS, default 30s) used ONLY by init's pushes. pushRepoDirectly / pushRepoBranch / autoPushViaMR / updateReports gain an opts.initPush flag threaded through the branch-worktree chain; init's 4 push call sites pass it. 3. New real-git regression test: a credential-less push to a 401 HTTP remote (with helpers stripped) is killed by the spawn-level timeout instead of hanging. Factory tests assert the timeout is NOT global. Co-Authored-By: Claude Code --- src/__tests__/git.test.ts | 75 +++++++++++---- src/__tests__/init-hang-regression.test.ts | 105 +++++++++++++++++++++ src/__tests__/init.test.ts | 2 + src/index.ts | 6 ++ src/init.ts | 7 +- src/utils/branch-worktree.ts | 21 +++-- src/utils/git.ts | 102 ++++++++++++++------ src/utils/reports-branch.ts | 5 +- 8 files changed, 264 insertions(+), 59 deletions(-) diff --git a/src/__tests__/git.test.ts b/src/__tests__/git.test.ts index a5ed66b42..229225371 100644 --- a/src/__tests__/git.test.ts +++ b/src/__tests__/git.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // Mock simple-git before importing const mockGit = { @@ -20,6 +20,9 @@ const mockGit = { pull: vi.fn(), fetch: vi.fn(), raw: vi.fn(), + // createGit / createGitForInitPush apply GIT_TERMINAL_PROMPT=0 via the + // .env() chainable; mockReturnThis keeps the chain alive. + env: vi.fn().mockReturnThis(), }; // Capture the options every createGit() call passes to simple-git, so tests @@ -61,41 +64,77 @@ vi.mock('../utils/logger.js', () => ({ import { generateBranchName, pushRepoBranch, checkoutMaster, pushRepoDirectly, initRepo, configureGitUser, getHeadRev, resetToCleanMaster, isMetadataOnlyDiff, isGitRepo, normalizeRepoUrlForCompare, remotesMatch, redactGitCredentials, pullRepo, pullRepoFastForward, pushLearningToOrigin } from '../utils/git.js'; import fse from 'fs-extra'; -import { createGit } from '../utils/git.js'; +import { createGit, createGitForInitPush, disableGitTerminalPrompt } from '../utils/git.js'; describe('createGit', () => { // The init-hang bug: a push with missing credentials opened an invisible - // prompt (no tty) and blocked forever; a stuck network op also blocked - // forever. Every git instance must therefore carry both guards. + // prompt (no tty) and blocked forever. The credential-prompt guard is set + // process-wide by disableGitTerminalPrompt (tested separately), NOT per + // simple-git instance — simple-git's .env() replaces the whole child env, + // which would drop PATH/HOME and break git. The spawn-level block timeout + // is NOT global either — it lives in createGitForInitPush so it cannot kill + // slow clones/fetches elsewhere. beforeEach(() => { simpleGitCalls.length = 0; }); - it('passes the spawn-level block timeout so a hung git subprocess is killed, not just un-awaited', () => { + it('does NOT add a global spawn timeout (would kill legitimate slow clones/fetches)', () => { createGit('/some/path'); + const options = simpleGitCalls.at(-1)![0] as { timeout?: unknown }; + expect(options.timeout).toBeUndefined(); + }); + + it('forwards basePath as baseDir', () => { + createGit('/some/path'); + const options = simpleGitCalls.at(-1)![0] as { baseDir?: string }; + expect(options.baseDir).toBe('/some/path'); + }); +}); + +describe('createGitForInitPush', () => { + beforeEach(() => { + simpleGitCalls.length = 0; + }); + + it('adds the spawn-level block timeout so a hung init push subprocess is killed', () => { + createGitForInitPush('/some/path'); const options = simpleGitCalls.at(-1)![0] as { timeout?: { block?: number } }; // simple-git's timeout.block actually kills the spawned process; a // Promise.race-style timeout would leave it running. expect(options.timeout?.block).toBe(30_000); }); - it('sets GIT_TERMINAL_PROMPT=0 so missing credentials fail fast instead of hanging on a prompt', () => { - createGit('/some/path'); - const options = simpleGitCalls.at(-1)![0] as { env?: Record }; - expect(options.env?.GIT_TERMINAL_PROMPT).toBe('0'); + it('honors TEAMAI_INIT_PUSH_TIMEOUT_MS for slow links', () => { + const prev = process.env.TEAMAI_INIT_PUSH_TIMEOUT_MS; + process.env.TEAMAI_INIT_PUSH_TIMEOUT_MS = '120000'; + try { + createGitForInitPush('/some/path'); + const options = simpleGitCalls.at(-1)![0] as { timeout?: { block?: number } }; + expect(options.timeout?.block).toBe(120_000); + } finally { + if (prev === undefined) delete process.env.TEAMAI_INIT_PUSH_TIMEOUT_MS; + else process.env.TEAMAI_INIT_PUSH_TIMEOUT_MS = prev; + } }); +}); - it('forwards basePath as baseDir', () => { - createGit('/some/path'); - const options = simpleGitCalls.at(-1)![0] as { baseDir?: string }; - expect(options.baseDir).toBe('/some/path'); +describe('disableGitTerminalPrompt', () => { + // The credential-prompt guard must be process-wide so every git subprocess + // inherits it — but it must not clobber an explicit user override. + afterEach(() => { + delete process.env.GIT_TERMINAL_PROMPT; }); - it('creates an instance without a basePath too (guards still applied)', () => { - createGit(); - const options = simpleGitCalls.at(-1)![0] as { timeout?: { block?: number }; env?: Record }; - expect(options.timeout?.block).toBe(30_000); - expect(options.env?.GIT_TERMINAL_PROMPT).toBe('0'); + it('sets GIT_TERMINAL_PROMPT=0 process-wide so git fails fast on missing credentials', () => { + delete process.env.GIT_TERMINAL_PROMPT; + disableGitTerminalPrompt(); + expect(process.env.GIT_TERMINAL_PROMPT).toBe('0'); + }); + + it('is idempotent and preserves an explicit user override', () => { + process.env.GIT_TERMINAL_PROMPT = '1'; // user wants prompts + disableGitTerminalPrompt(); + expect(process.env.GIT_TERMINAL_PROMPT).toBe('1'); }); }); diff --git a/src/__tests__/init-hang-regression.test.ts b/src/__tests__/init-hang-regression.test.ts index d1dd0aad0..b72358ae9 100644 --- a/src/__tests__/init-hang-regression.test.ts +++ b/src/__tests__/init-hang-regression.test.ts @@ -16,6 +16,7 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import fs from 'node:fs'; +import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; import { simpleGit } from 'simple-git'; @@ -128,3 +129,107 @@ describe('createGit hang guards (init-hang regression)', () => { ).resolves.toBeUndefined(); }); }); + +describe('credential-prompt guard (init-hang regression)', () => { + // Reproduces the original bug precisely: a push to a remote that requires + // credentials, with NO credential helper available. Without + // GIT_TERMINAL_PROMPT=0, git opens an interactive username/password prompt + // on the tty — and since teamai runs git with no tty, the push hangs + // forever. With the guard, git fails immediately with "terminal prompts + // disabled" / "could not read Username". + // + // We stand up a local HTTP git endpoint that always answers 401 Unauthorized + // (demanding Basic auth), then push to it with every credential source + // stripped: empty HOME, no GIT_CONFIG_GLOBAL, no credential helper. The push + // must fail fast rather than block on a prompt. + let server: http.Server; + let remoteUrl: string; + + beforeEach(async () => { + server = http.createServer((_req, res) => { + // Always require authentication — git will look for a credential, find + // none, and (without the guard) try to prompt. + res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="teamai-test"' }); + res.end('Unauthorized'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const addr = server.address(); + if (!addr || typeof addr === 'string') throw new Error('failed to bind test server'); + remoteUrl = `http://127.0.0.1:${addr.port}/team.git`; + }); + + afterEach(() => new Promise((resolve) => server.close(() => resolve()))); + + it('a credential-less push to a 401 HTTP remote fails fast instead of prompting', async () => { + const clone = path.join(tmp, 'credless-clone'); + fs.mkdirSync(clone, { recursive: true }); + const git = simpleGit(clone); + await git.init(['--initial-branch=main']); + await git.addConfig('user.email', 't@t.com'); + await git.addConfig('user.name', 't'); + await git.addRemote('origin', remoteUrl); + // An initial commit so `main` exists; pushRepoDirectly then stages and + // commits the member file itself (otherwise it sees nothing to commit and + // returns without pushing). + fs.writeFileSync(path.join(clone, 'README.md'), 'seed\n'); + await git.add(['README.md']); + await git.commit('seed'); + fs.mkdirSync(path.join(clone, 'members'), { recursive: true }); + fs.writeFileSync(path.join(clone, 'members', 'alice.yaml'), 'username: alice\n'); + + // Isolate credentials so git reaches the terminal-prompt path rather than + // a credential helper: point GIT_CONFIG_GLOBAL at a temp config that clears + // every helper (`[credential]\thelper =`), plus an empty HOME so no + // user-level helper is discovered. This is the state that made the old push + // hang — and the state GIT_TERMINAL_PROMPT=0 exists to rescue. + const prevHome = process.env.HOME; + const prevGlobal = process.env.GIT_CONFIG_GLOBAL; + const prevSystem = process.env.GIT_CONFIG_NOSYSTEM; + const prevPrompt = process.env.GIT_TERMINAL_PROMPT; + const isolatedConfig = path.join(tmp, 'no-helper.gitconfig'); + fs.writeFileSync( + isolatedConfig, + '[credential]\n\thelper =\n[user]\n\tname = t\n\temail = t@t.com\n', + ); + process.env.HOME = path.join(tmp, 'empty-home'); + fs.mkdirSync(process.env.HOME, { recursive: true }); + process.env.GIT_CONFIG_GLOBAL = isolatedConfig; + process.env.GIT_CONFIG_NOSYSTEM = '1'; + // The CLI sets this process-wide at startup (disableGitTerminalPrompt). + // This test imports pushRepoDirectly directly, so simulate that here. + process.env.GIT_TERMINAL_PROMPT = '0'; + // Shrink the init-push block timeout so a hung push is killed in a couple + // seconds instead of the default 30 (keeps the test fast). initPush:true + // routes through createGitForInitPush, whose timeout.block actually + // terminates the spawned git process — the real fix for a push that hangs + // on a credential prompt or helper. + const prevInitTimeout = process.env.TEAMAI_INIT_PUSH_TIMEOUT_MS; + process.env.TEAMAI_INIT_PUSH_TIMEOUT_MS = '3000'; + + const start = Date.now(); + try { + // initPush:true uses createGitForInitPush (spawn-level block timeout), + // mirroring how init actually calls this. The push must reject within + // the timeout budget — a regression to no spawn timeout would hang + // forever (a credential helper/401 can block past any await-only guard). + await expect( + pushRepoDirectly(clone, '[teamai] Register member: alice', ['members/alice.yaml'], { initPush: true }), + ).rejects.toThrow(); + } finally { + process.env.HOME = prevHome; + if (prevGlobal === undefined) delete process.env.GIT_CONFIG_GLOBAL; + else process.env.GIT_CONFIG_GLOBAL = prevGlobal; + if (prevSystem === undefined) delete process.env.GIT_CONFIG_NOSYSTEM; + else process.env.GIT_CONFIG_NOSYSTEM = prevSystem; + if (prevPrompt === undefined) delete process.env.GIT_TERMINAL_PROMPT; + else process.env.GIT_TERMINAL_PROMPT = prevPrompt; + if (prevInitTimeout === undefined) delete process.env.TEAMAI_INIT_PUSH_TIMEOUT_MS; + else process.env.TEAMAI_INIT_PUSH_TIMEOUT_MS = prevInitTimeout; + } + const elapsed = Date.now() - start; + + // The spawn-level block timeout (3s here) kills the hung push; without it + // the push would block indefinitely. Allow headroom for git startup. + expect(elapsed).toBeLessThan(10_000); + }, 30_000); +}); diff --git a/src/__tests__/init.test.ts b/src/__tests__/init.test.ts index abf05853c..d7ab06f31 100644 --- a/src/__tests__/init.test.ts +++ b/src/__tests__/init.test.ts @@ -13,6 +13,8 @@ const mockGit = { push: vi.fn(), revparse: vi.fn().mockResolvedValue('main'), raw: vi.fn(), + // createGit applies GIT_TERMINAL_PROMPT=0 via simple-git's .env() chainable. + env: vi.fn().mockReturnThis(), }; vi.mock('simple-git', () => ({ diff --git a/src/index.ts b/src/index.ts index 0b40234f7..f6a4652ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,16 @@ import { createRequire } from 'node:module'; import { Command, Option } from 'commander'; import { setVerbose, setSilent, log } from './utils/logger.js'; +import { disableGitTerminalPrompt } from './utils/git.js'; import type { GlobalOptions, LocalConfig } from './types.js'; import { TEAMAI_HOOK_SUBCOMMANDS } from './hooks.js'; import { registerPackagesCommand } from './pkg/register-command.js'; +// Fail fast on a missing git credential instead of hanging on an invisible +// prompt (teamai runs git with no tty). Set once at module load so every +// command's git subprocesses inherit it. +disableGitTerminalPrompt(); + // Commands that migrate a legacy `/.teamai/` into the partition on first // run (issue #374 P1-3). Only write commands trigger it; read-only commands rely // on the double-read fallback, and hook-dispatch is excluded outright (see below). diff --git a/src/init.ts b/src/init.ts index 11923be26..4c81c3eec 100644 --- a/src/init.ts +++ b/src/init.ts @@ -995,7 +995,7 @@ export async function initSelfRepo(options: GlobalOptions & { ? `[teamai] Register member: ${username}` : `[teamai] Update member roster: ${username}`, }; - }), + }, { initPush: true }), 30_000, 'Member registration push', ); @@ -1432,7 +1432,7 @@ export async function init(options: GlobalOptions & { 'docs/.gitkeep', 'env/.gitkeep', 'members/.gitkeep', - ]), + ], { initPush: true }), 30_000, 'Skeleton push', ); @@ -1470,7 +1470,7 @@ export async function init(options: GlobalOptions & { ? `[teamai] Register member: ${username}` : `[teamai] Update member roster: ${username}`, }; - }), + }, { initPush: true }), 30_000, 'Member registration push', ); @@ -1534,6 +1534,7 @@ export async function init(options: GlobalOptions & { ['teamai.yaml'], mrTeamConfig, mrLocalConfig, + { initPush: true }, ), 30_000, 'Reviewer config push', diff --git a/src/utils/branch-worktree.ts b/src/utils/branch-worktree.ts index 3f5e7732e..9fe80d4b0 100644 --- a/src/utils/branch-worktree.ts +++ b/src/utils/branch-worktree.ts @@ -27,7 +27,7 @@ import path from 'node:path'; import fse from 'fs-extra'; import type { SimpleGit } from 'simple-git'; -import { createGit, isGitRepo, commitSkippingHooks, isDedicatedRepoRoot } from './git.js'; +import { createGit, createGitForInitPush, isGitRepo, commitSkippingHooks, isDedicatedRepoRoot } from './git.js'; import { acquireLock, releaseLock } from '../update.js'; import { ensureDir, writeFile, pathExists } from './fs.js'; import { log } from './logger.js'; @@ -293,15 +293,22 @@ export interface BranchWrite { message: string; } +/** Options threaded through commitAndPushAt and its callers. */ +export interface BranchPushOptions { + pushIfUnchanged?: boolean; + /** Use the spawn-level block-timeout git factory (init pushes). */ + initPush?: boolean; +} + /** Commit `files` in an already-locked worktree and push them. */ async function commitAndPushAt( spec: BranchWorktreeSpec, wt: string, message: string, files: string[], - options: { pushIfUnchanged?: boolean } = {}, + options: BranchPushOptions = {}, ): Promise { - const git = createGit(wt); + const git = options.initPush ? createGitForInitPush(wt) : createGit(wt); await git.add(files); const status = await git.status(); @@ -367,7 +374,7 @@ async function commitAndPushImpl( localConfig: LocalConfig, message: string, files: string[], - options: { pushIfUnchanged?: boolean } = {}, + options: BranchPushOptions = {}, ): Promise { const lockPath = lockFilePath(spec, localConfig); const locked = await acquireLock(lockPath); @@ -398,7 +405,7 @@ async function updateImpl( spec: BranchWorktreeSpec, localConfig: LocalConfig, write: (worktree: string) => Promise, - options: { pushIfUnchanged?: boolean } = {}, + options: BranchPushOptions = {}, ): Promise { if (!usesBranchWorktree(localConfig)) { throw new Error(`update() needs a branch-backed repo, and ${spec.branch} has none for kind: 'http'`); @@ -631,13 +638,13 @@ export interface BranchWorktree { update( localConfig: LocalConfig, write: (worktree: string) => Promise, - options?: { pushIfUnchanged?: boolean }, + options?: BranchPushOptions, ): Promise; commitAndPush( localConfig: LocalConfig, message: string, files: string[], - options?: { pushIfUnchanged?: boolean }, + options?: BranchPushOptions, ): Promise; refresh(localConfig: LocalConfig, options?: EnsureWorktreeOptions): Promise; } diff --git a/src/utils/git.ts b/src/utils/git.ts index 413ef35af..0f137004d 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -6,24 +6,48 @@ import simpleGit, { type SimpleGit } from 'simple-git'; import { log } from './logger.js'; /** - * Per-spawn guard that stops git ever blocking on an interactive credential - * prompt. With this unset, a missing PAT/SSH key makes `git push` open a - * username/password prompt on the tty instead of failing — and since teamai - * runs git as a subprocess with no tty, the push hangs forever, stalling init - * before the local config is written. `GIT_TERMINAL_PROMPT=0` makes git fail - * fast with a clear "could not read Username" error instead. + * Disables git's interactive credential prompt for the whole teamai process. + * + * With this unset, a push with missing credentials opens a username/password + * prompt on the tty — and since teamai runs git as a subprocess with no tty, + * the push hangs forever, stalling init before the local config is written. + * `GIT_TERMINAL_PROMPT=0` makes git fail fast with "could not read Username" + * instead. + * + * Set once at process start (see {@link disableGitTerminalPrompt}) so every + * spawned git subprocess inherits it. We do NOT use simple-git's `.env()` + * chainable for this: both its overloads *replace* the child's entire + * environment (`this.env` is used verbatim as the spawn `env`, not merged + * with `process.env`), which would drop PATH/HOME and break git itself. */ -const NO_PROMPT_ENV = { GIT_TERMINAL_PROMPT: '0' } as const; +const NO_PROMPT_VAR = 'GIT_TERMINAL_PROMPT' as const; +const NO_PROMPT_VAL = '0' as const; /** - * Hard ceiling for any single git subprocess. simple-git's `timeout.block` - * actually kills the spawned git process when no data arrives for this long - * (unlike a Promise.race, which only stops awaiting while the child keeps - * running and keeps the Node process alive). 30s covers every legitimate git - * operation teamai issues; a hung push/clone/fetch is killed at the process - * level, not just the await level. + * Set `GIT_TERMINAL_PROMPT=0` process-wide so no git subprocess can block on + * an interactive credential prompt. Idempotent; safe to call multiple times. + * Preserves an explicit user override if one is already set. */ -const GIT_BLOCK_TIMEOUT_MS = 30_000; +export function disableGitTerminalPrompt(): void { + if (process.env[NO_PROMPT_VAR] === undefined) { + process.env[NO_PROMPT_VAR] = NO_PROMPT_VAL; + } +} + +/** + * Block timeout for git subprocesses spawned during `teamai init` pushes. + * simple-git's `timeout.block` kills the spawned process when it produces no + * output for this long (unlike `withTimeout`, a Promise.race that only stops + * awaiting while the child keeps running and holds the Node event loop open). + * + * Scoped to init's network pushes only: a global ceiling here would also kill + * legitimate slow clones/fetches/rebases in unrelated commands. The env var + * lets a slow link or a very large team repo raise the ceiling without a new + * release. Read at call time (not module load) so tests can override it. + */ +function initPushBlockTimeoutMs(): number { + return Number.parseInt(process.env.TEAMAI_INIT_PUSH_TIMEOUT_MS ?? '', 10) || 30_000; +} /** * Create a SimpleGit instance for a given base path. @@ -31,18 +55,26 @@ const GIT_BLOCK_TIMEOUT_MS = 30_000; * Authentication is handled by the provider's remote URL or by normal Git * facilities such as credential helpers, SSH config, and SSH agents. * - * Every instance gets a 30s block timeout (kills a hung git subprocess at the - * spawn level) and `GIT_TERMINAL_PROMPT=0` (so a missing credential fails fast - * instead of hanging on an invisible prompt). Together these make the old - * "init hangs forever on push" failure mode structurally impossible. + * Every instance inherits the process-wide `GIT_TERMINAL_PROMPT=0` set by + * {@link disableGitTerminalPrompt}, so a missing credential fails fast + * instead of hanging on a prompt that can never be answered. */ export function createGit(basePath?: string): SimpleGit { - const options = { - timeout: { block: GIT_BLOCK_TIMEOUT_MS }, - env: NO_PROMPT_ENV, - ...(basePath ? { baseDir: basePath } : {}), - }; - return simpleGit(options); + return basePath ? simpleGit({ baseDir: basePath }) : simpleGit(); +} + +/** + * Like {@link createGit}, but additionally kills any git subprocess that + * produces no output for the configured init-push block timeout. Reserved for + * `teamai init` pushes: a hung push is killed at the process level (the + * `withTimeout` wrapper around the await is only a second layer), while + * unrelated commands keep simple-git's default of no spawn timeout. + */ +export function createGitForInitPush(basePath?: string): SimpleGit { + const block = initPushBlockTimeoutMs(); + return basePath + ? simpleGit({ baseDir: basePath, timeout: { block } }) + : simpleGit({ timeout: { block } }); } /** @@ -364,9 +396,17 @@ export async function getDefaultBranch(localPath: string): Promise { /** * Push directly to whatever branch is checked out, whether that is `main`, * `master` or anything else. Used during init for first-time setup, and by CI. + * + * `opts.initPush` selects the spawn-level block timeout factory used by init + * (see {@link createGitForInitPush}); other callers get the plain factory. */ -export async function pushRepoDirectly(localPath: string, message: string, files: string[]): Promise { - const git = createGit(localPath); +export async function pushRepoDirectly( + localPath: string, + message: string, + files: string[], + opts: { initPush?: boolean } = {}, +): Promise { + const git = opts.initPush ? createGitForInitPush(localPath) : createGit(localPath); const existingFiles = []; for (const f of files) { const fullPath = fs.existsSync(`${localPath}/${f}`); @@ -447,10 +487,11 @@ export async function autoPushViaMR( files: string[], teamConfig: { repo: string; provider?: string; reviewers?: string[] }, localConfig: { repo: { remote: string; localPath: string }; username: string }, + opts: { initPush?: boolean } = {}, ): Promise { try { const branchName = generateBranchName(localConfig.username); - const pushed = await pushRepoBranch(repoPath, message, files, branchName); + const pushed = await pushRepoBranch(repoPath, message, files, branchName, { initPush: opts.initPush }); if (!pushed) { log.debug('[git] autoPushViaMR: nothing to commit'); return null; @@ -532,15 +573,18 @@ export async function remoteBranchExists( * force-pushed, which updates that PR in place instead of opening another one. * If the rebuilt tree matches what the remote branch already holds, nothing is * pushed and the function returns false. + * + * `opts.initPush` selects the spawn-level block timeout factory used by init + * (see {@link createGitForInitPush}); other callers get the plain factory. */ export async function pushRepoBranch( localPath: string, message: string, files: string[], branchName: string, - opts: { reuseBranch?: boolean } = {}, + opts: { reuseBranch?: boolean; initPush?: boolean } = {}, ): Promise { - const git = createGit(localPath); + const git = opts.initPush ? createGitForInitPush(localPath) : createGit(localPath); if (opts.reuseBranch) { // Fetch so the tree comparison below can see the remote branch's content. diff --git a/src/utils/reports-branch.ts b/src/utils/reports-branch.ts index 262176f9e..84e1875c9 100644 --- a/src/utils/reports-branch.ts +++ b/src/utils/reports-branch.ts @@ -14,6 +14,7 @@ import { pathExists } from './fs.js'; import { createBranchWorktree, isPublished, + type BranchPushOptions, type BranchWrite, type EnsureWorktreeOptions, } from './branch-worktree.js'; @@ -64,7 +65,7 @@ export async function commitAndPushReports( localConfig: LocalConfig, message: string, files: string[], - options: { pushIfUnchanged?: boolean } = {}, + options: BranchPushOptions = {}, ): Promise { return isPublished(await reportsBranch.commitAndPush(localConfig, message, files, options)); } @@ -73,7 +74,7 @@ export async function commitAndPushReports( export async function updateReports( localConfig: LocalConfig, write: (worktree: string) => Promise, - options: { pushIfUnchanged?: boolean } = {}, + options: BranchPushOptions = {}, ): Promise { return isPublished(await reportsBranch.update(localConfig, write, options)); } From 2560d0266014f9c87c4682f9a85fd103a5d3dfc3 Mon Sep 17 00:00:00 2001 From: irootech Date: Tue, 22 Sep 2026 11:30:39 +0800 Subject: [PATCH 5/8] fix(init): thread initPush through worktree setup + drop README troubleshooting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address third-round review on PR #677: 1. The spawn-level timeout did not cover reports-worktree initialization. updateImpl called ensureWorktree before reaching the timed git instance, so cold-start ls-remote/fetch/first-push still used ordinary createGit with no subprocess timeout — a first-time init could hang indefinitely. Thread initPush through ensureWorktree (and syncWorktree, remoteBranchExists, createOrphanWorktree) so every git op during a guarded init push uses createGitForInitPush. 2. The README troubleshooting section overstated the fix (claimed every git subprocess gets a 30s timeout; only selected init-push instances do). Rather than reword five translations, drop the section entirely — it was added by this PR and is not essential documentation, which also satisfies the surgical-change rule. Co-Authored-By: Claude Code --- README.ja.md | 12 ------------ README.ko.md | 12 ------------ README.md | 12 ------------ README.th.md | 12 ------------ README.zh-CN.md | 12 ------------ src/utils/branch-worktree.ts | 37 +++++++++++++++++++----------------- 6 files changed, 20 insertions(+), 77 deletions(-) diff --git a/README.ja.md b/README.ja.md index 0eed36814..a60f152f4 100644 --- a/README.ja.md +++ b/README.ja.md @@ -316,18 +316,6 @@ teamai recall maintenance --update-quality # draft updates for stale skill | `teamai doctor` | 設定の問題を診断(`--json` で JSON 出力、CI・hook・agent 向け)| | `teamai uninstall` | すべての teamai リソースと hooks を削除 | -## トラブルシューティング - -### `teamai init` が "Registered as team member" の後で固まる - -**症状**:init が `✔ Registered as team member: <あなた>` で停止し、その後の出力もエラーもありません。`~/.teamai/config.yaml` は書き込まれず、skills も取得されません。 - -**根本原因**:チームリポジトリのデフォルトブランチが保護されています(`push: No one` — チームリポジトリでは一般的)。旧バージョンの teamai はメンバーファイルを `git push` でデフォルトブランチに直接プッシュしており、(a) サーバーに拒否され、(b) プッシュにタイムアウトがないため、認証情報がない場合に失敗せず永久にハングしていました。init はローカル設定のステップに到達できません。 - -**修正**:PR #677 を含むバージョンにアップグレードしてください。メンバー登録と reviewer 設定は `teamai-reports` orphan ブランチ / ブランチ + MR 経由になり(保護されたデフォルトブランチには直接プッシュしません)、すべての git サブプロセスに 30 秒のタイムアウトと `GIT_TERMINAL_PROMPT=0` が付きます。ハングした、または認証情報のないプッシュは init を止めることなく即座に失敗します。init は必ず完了し、ローカル設定 + skills を書き込みます。プッシュ/MR の失敗は警告のみです。 - -旧バージョンでの手動回避策:`~/.teamai/team-repo` で `members/<あなた>.yaml` を feature ブランチにプッシュし、デフォルトブランチへ MR を作成してマージした後、`teamai init` を再実行してください。 - ## ライセンス [MIT](LICENSE) diff --git a/README.ko.md b/README.ko.md index 50afe7b53..0c2918fed 100644 --- a/README.ko.md +++ b/README.ko.md @@ -316,18 +316,6 @@ teamai recall maintenance --update-quality # draft updates for stale skill | `teamai doctor` | 구성 문제 진단 (`--json`으로 JSON 출력, CI·hook·agent용) | | `teamai uninstall` | 모든 teamai 리소스와 hooks 제거 | -## 문제 해결 - -### `teamai init`이 "Registered as team member" 후 멈춤 - -**증상**: init이 `✔ Registered as team member: <본인>`에서 멈추고 이후 출력도 에러도 없습니다. `~/.teamai/config.yaml`이 작성되지 않고 skills도 가져오지 않습니다. - -**근본 원인**: 팀 저장소의 기본 브랜치가 보호되어 있습니다(`push: No one` — 팀 저장소에서 흔함). 이전 버전의 teamai는 멤버 파일을 `git push`로 기본 브랜치에 직접 밀었는데, (a) 서버가 거부하고 (b) push에 타임아웃이 없어 자격 증명이 없으면 실패하지 않고 영원히 걸렸습니다. init이 로컬 설정 단계에 도달하지 못합니다. - -**수정**: PR #677이 포함된 버전으로 업그레이드하세요. 멤버 등록과 reviewer 구성은 `teamai-reports` orphan 브랜치 / 브랜치 + MR로 진행되며(보호된 기본 브랜치에 직접 push하지 않음), 모든 git 서브프로세스에 30초 타임아웃과 `GIT_TERMINAL_PROMPT=0`이 적용됩니다. 멈추거나 자격 증명 없는 push는 init을 멈추지 않고 즉시 실패합니다. init은 항상 완료되어 로컬 설정 + skills를 기록하며, push/MR 실패는 경고일 뿐입니다. - -이전 버전에서의 수동 해결책: `~/.teamai/team-repo`에서 `members/<본인>.yaml`을 feature 브랜치에 push하고 기본 브랜치로 MR을 만들어 병합한 뒤 `teamai init`을 다시 실행하세요. - ## 라이선스 [MIT](LICENSE) diff --git a/README.md b/README.md index 4bd2b69d8..0a62a36fa 100644 --- a/README.md +++ b/README.md @@ -316,18 +316,6 @@ Insight into how the team actually uses its AI tools, and a starting point for t | `teamai doctor` | Diagnose configuration issues (`--json` for CI, hooks and agents) | | `teamai uninstall` | Remove all teamai resources and hooks | -## Troubleshooting - -### `teamai init` hangs after "Registered as team member" - -**Symptom**: init stops at `✔ Registered as team member: ` with no further output and no error. No `~/.teamai/config.yaml` is written, no skills are pulled. - -**Root cause**: the team repo's default branch is protected (`push: No one` — common for team repos). Older teamai versions pushed the member file directly to the default branch via `git push`, which (a) is rejected by the server and (b) has no timeout, so a missing-credential push hangs forever instead of failing. Init never reaches the local-config step. - -**Fix**: upgrade to a version with PR #677. Member registration and reviewer-config changes go through the `teamai-reports` orphan branch / a branch + MR (never the protected default branch), and every git subprocess gets a 30s timeout plus `GIT_TERMINAL_PROMPT=0`, so a hung or credential-less push fails fast instead of stalling init. Init always completes and writes the local config + skills; the push/MR failure is only a warning. - -On an older version, the manual workaround is to register the member yourself via a branch + MR (`~/.teamai/team-repo`, push `members/.yaml` on a feature branch, open an MR to the default branch), merge it, then re-run `teamai init`. - ## License [MIT](LICENSE) diff --git a/README.th.md b/README.th.md index 72b239993..e9d416f95 100644 --- a/README.th.md +++ b/README.th.md @@ -316,18 +316,6 @@ teamai recall maintenance --update-quality # draft updates for stale skill | `teamai doctor` | วินิจฉัยปัญหาคอนฟิก (`--json` แสดงผลเป็น JSON สำหรับ CI, hook และ agent) | | `teamai uninstall` | ลบทรัพยากรและ hooks ของ teamai ทั้งหมด | -## การแก้ปัญหา - -### `teamai init` ค้างหลัง "Registered as team member" - -**อาการ**: init หยุดที่ `✔ Registered as team member: <คุณ>` แล้วไม่มีผลลัพธ์หรือข้อผิดพลาดใด ๆ ต่อ ไม่มีการเขียน `~/.teamai/config.yaml` และไม่ดึง skills - -**สาเหตุ**: สาขา default ของทีม repo ถูกป้องกันไว้ (`push: No one` — เป็นเรื่องปกติของ team repo) เวอร์ชันเก่าของ teamai push ไฟล์สมาชิกลงสาขา default โดยตรงด้วย `git push` ซึ่ง (a) ถูกเซิร์ฟเวอร์ปฏิเสธ และ (b) push ไม่มี timeout ทำให้เมื่อขาด credential มันค้างตลอดไปแทนที่จะล้มเหลว init จึงไปไม่ถึงขั้นตอน config ในเครื่อง - -**การแก้ไข**: อัปเกรดเป็นเวอร์ชันที่มี PR #677 การลงทะเบียนสมาชิกและการตั้งค่า reviewer จะไปทาง orphan branch `teamai-reports` / branch + MR (ไม่ push ตรงไปสาขา default ที่ถูกป้องกัน) และ git subprocess ทุกตัวมี timeout 30 วินาทีพร้อม `GIT_TERMINAL_PROMPT=0` push ที่ค้างหรือไม่มี credential จะล้มเหลวทันทีโดยไม่บล็อก init init เสร็จสมบูรณ์เสมอและเขียน config + skills ในเครื่อง ความล้มเหลวของ push/MR เป็นเพียงคำเตือน - -วิธีแก้ชั่วคราวบนเวอร์ชันเก่า: ลงทะเบียนสมาชิกเองผ่าน branch + MR (push `members/<คุณ>.yaml` บน feature branch ใน `~/.teamai/team-repo` แล้วเปิด MR ไปสาขา default) เมื่อ merge แล้วรัน `teamai init` อีกครั้ง - ## ใบอนุญาต [MIT](LICENSE) diff --git a/README.zh-CN.md b/README.zh-CN.md index 000dc54a0..1a2aaf44b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -316,18 +316,6 @@ teamai recall maintenance --update-quality # 为过时 skills / docs 生 | `teamai doctor` | 诊断配置问题(`--json` 输出 JSON,供 CI、hook 与 agent 消费)| | `teamai uninstall` | 移除所有 teamai 资源和 hooks | -## 故障排查 - -### `teamai init` 在 "Registered as team member" 后卡住 - -**现象**:init 停在 `✔ Registered as team member: <你>`,之后没有任何输出也没有报错。`~/.teamai/config.yaml` 未写入,skills 也没有拉取。 - -**根因**:团队仓库的默认分支受保护(`push: No one`——团队仓常见配置)。旧版 teamai 直接用 `git push` 把成员文件推到默认分支,(a) 会被服务端拒绝,(b) push 没有超时,凭据缺失时会永远挂起而不是失败。init 因此永远走不到本地配置那一步。 - -**修复**:升级到包含 PR #677 的版本。成员注册与 reviewer 配置改走 `teamai-reports` 孤儿分支 / 分支 + MR(不再直推受保护的默认分支),且每个 git 子进程都有 30 秒超时和 `GIT_TERMINAL_PROMPT=0` 守护——挂起或缺凭据的 push 会快速失败而不是卡住 init。init 总能完成并写入本地配置 + skills,push/MR 失败只是一条警告。 - -旧版本上的手动解法:自己通过分支 + MR 注册成员(在 `~/.teamai/team-repo` 里把 `members/<你>.yaml` 推到 feature 分支,向默认分支提 MR),合入后重新跑 `teamai init`。 - ## 许可证 [MIT](LICENSE) diff --git a/src/utils/branch-worktree.ts b/src/utils/branch-worktree.ts index 9fe80d4b0..007c80ce9 100644 --- a/src/utils/branch-worktree.ts +++ b/src/utils/branch-worktree.ts @@ -114,8 +114,8 @@ async function nothingLeftToPush(git: SimpleGit, spec: BranchWorktreeSpec): Prom } } -async function remoteBranchExists(spec: BranchWorktreeSpec, repoRoot: string): Promise { - const git = createGit(repoRoot); +async function remoteBranchExists(spec: BranchWorktreeSpec, repoRoot: string, initPush = false): Promise { + const git = initPush ? createGitForInitPush(repoRoot) : createGit(repoRoot); try { const res = await git.listRemote(['--heads', 'origin', spec.branch]); return typeof res === 'string' && res.trim().length > 0; @@ -143,6 +143,8 @@ export interface EnsureWorktreeOptions { * view without changing origin. */ pushIfCreated?: boolean; + /** Use the spawn-level block-timeout git factory (init pushes). */ + initPush?: boolean; } async function ensureWorktree( @@ -168,9 +170,10 @@ async function ensureWorktree( // worktree gitdir (`/.git/worktrees/`) is gone and git ops // fail with "not a git repository". Probe a real git command and fall through // to remove+recreate when the link is stale. + const makeGit = options.initPush ? createGitForInitPush : createGit; if (await isGitRepo(wt)) { try { - await createGit(wt).revparse(['--is-inside-work-tree']); + await makeGit(wt).revparse(['--is-inside-work-tree']); return wt; } catch { // stale/dangling worktree link (clone was re-cloned/pruned) — recreate below. @@ -183,7 +186,7 @@ async function ensureWorktree( } await ensureDir(path.dirname(wt)); - const git = createGit(repoRoot); + const git = makeGit(repoRoot); // Prune any dangling worktree registration left from a previous removal. try { @@ -192,7 +195,7 @@ async function ensureWorktree( // best effort } - if (await remoteBranchExists(spec, repoRoot)) { + if (await remoteBranchExists(spec, repoRoot, options.initPush)) { // Remote branch exists: fetch and check it out into the worktree. try { await git.fetch(['origin', spec.branch]); @@ -214,15 +217,15 @@ async function ensureWorktree( if (branches.all.includes(spec.branch)) { await git.raw(['worktree', 'add', wt, spec.branch]); } else { - await createOrphanWorktree(spec, repoRoot, wt); + await createOrphanWorktree(spec, repoRoot, wt, options.initPush); await writeWorktreeGitignore(wt); - const wtGit = createGit(wt); + const wtGit = makeGit(wt); await wtGit.add(['.gitignore']); await commitSkippingHooks(wtGit, spec.initCommitMessage); } if (options.pushIfCreated !== false) { try { - await createGit(wt).push(['-u', 'origin', spec.branch]); + await makeGit(wt).push(['-u', 'origin', spec.branch]); } catch (e) { log.debug(`[${spec.logTag}] initial push skipped: ${(e as Error).message}`); } @@ -236,8 +239,8 @@ async function ensureWorktree( * Create an orphan-branch worktree. Uses the modern `--orphan` flag (git 2.42+) * and falls back to the detach + `checkout --orphan` dance for older git. */ -async function createOrphanWorktree(spec: BranchWorktreeSpec, repoRoot: string, wt: string): Promise { - const git = createGit(repoRoot); +async function createOrphanWorktree(spec: BranchWorktreeSpec, repoRoot: string, wt: string, initPush = false): Promise { + const git = initPush ? createGitForInitPush(repoRoot) : createGit(repoRoot); try { // git 2.42+: create a worktree on a fresh orphan branch directly. // The branch name must be given via -b; a positional after is treated @@ -245,7 +248,7 @@ async function createOrphanWorktree(spec: BranchWorktreeSpec, repoRoot: string, await git.raw(['worktree', 'add', '--orphan', '-b', spec.branch, wt]); // The --orphan worktree may inherit the index/files from HEAD in some git // versions; clear tracked entries so the branch starts empty. - const wtGit = createGit(wt); + const wtGit = initPush ? createGitForInitPush(wt) : createGit(wt); try { await wtGit.raw(['rm', '-rf', '--cached', '.']); } catch { @@ -255,7 +258,7 @@ async function createOrphanWorktree(spec: BranchWorktreeSpec, repoRoot: string, } catch { // Older git (<2.42): detach a worktree at HEAD, then orphan-checkout inside it. await git.raw(['worktree', 'add', '--detach', wt, 'HEAD']); - const wtGit = createGit(wt); + const wtGit = initPush ? createGitForInitPush(wt) : createGit(wt); await wtGit.raw(['checkout', '--orphan', spec.branch]); try { await wtGit.raw(['rm', '-rf', '--cached', '.']); @@ -384,7 +387,7 @@ async function commitAndPushImpl( } try { - const wt = await ensureWorktree(spec, localConfig); + const wt = await ensureWorktree(spec, localConfig, { initPush: options.initPush }); return await commitAndPushAt(spec, wt, message, files, options); } catch (e) { log.debug(`[${spec.logTag}] commitAndPush failed (non-blocking): ${(e as Error).message}`); @@ -418,9 +421,9 @@ async function updateImpl( } try { - const wt = await ensureWorktree(spec, localConfig); + const wt = await ensureWorktree(spec, localConfig, { initPush: options.initPush }); try { - await syncWorktree(spec, wt); + await syncWorktree(spec, wt, options.initPush); } catch (e) { log.debug(`[${spec.logTag}] sync before write failed, writing onto the local copy: ${(e as Error).message}`); } @@ -529,8 +532,8 @@ async function applyDirtySnapshot(spec: BranchWorktreeSpec, git: SimpleGit, sha: * `git stash` in another worktree of this repo is left alone. Stash-apply * conflicts restore the original uncommitted files, never conflict markers. */ -async function syncWorktree(spec: BranchWorktreeSpec, wt: string): Promise { - const git = createGit(wt); +async function syncWorktree(spec: BranchWorktreeSpec, wt: string, initPush = false): Promise { + const git = initPush ? createGitForInitPush(wt) : createGit(wt); const upstream = `origin/${spec.branch}`; try { await git.fetch(['origin', spec.branch]); From ec22505da291a99b7e9e56cb5aabbdd57e8105fb Mon Sep 17 00:00:00 2001 From: irootech Date: Tue, 22 Sep 2026 14:02:27 +0800 Subject: [PATCH 6/8] fix(init): unify init-push timeout + document TEAMAI_INIT_PUSH_TIMEOUT_MS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address round-4 review on PR #677: 1. withTimeout's 30s was hard-coded and did not honor TEAMAI_INIT_PUSH_TIMEOUT_MS, so the configurable ceiling was ineffective for operations meant to exceed 30s. Export initPushBlockTimeoutMs() from git.ts and use it in all four withTimeout call sites in init.ts, so the await guard and the spawn-level timeout share one configured value. 2. TEAMAI_INIT_PUSH_TIMEOUT_MS (and GIT_TERMINAL_PROMPT) were undocumented. Add an Environment variables subsection to the Configuration Reference in both usage-guide.md and usage-guide.zh-CN.md. Member registration stays on the teamai-reports orphan branch (upstream design — an orphan branch is not the protected default branch). The push is non-blocking and guarded by the spawn-level timeout threaded through worktree setup; a protected reports branch surfaces as a non-blocking warning, not a hang. Co-Authored-By: Claude Code --- docs/usage-guide.md | 7 +++++++ docs/usage-guide.zh-CN.md | 7 +++++++ src/init.ts | 10 +++++----- src/utils/git.ts | 2 +- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 27e31eb36..894af0a26 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1787,6 +1787,13 @@ teamai init --repo https://github.com/yourorg/yourrepo --scope user --role Date: Tue, 22 Sep 2026 14:20:45 +0800 Subject: [PATCH 7/8] fix(init): push reviewer config directly + exercise guarded factory in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address round-5 review on PR #677: 1. autoPushViaMR for reviewer config shelled out to a provider's pr-create CLI via spawnSync (gh pr create / TGit), which blocks the event loop — withTimeout's timer could never fire, so a stalled MR creation still blocked init. Reviewer config is part of teamai.yaml and belongs on the default branch, not behind an MR, so push it directly with pushRepoDirectly({initPush:true}) instead. This also fixes the round-5 P2: the generic git provider has no PR API, so MR-only routing regressed it — direct push works for unprotected default branches (the common case), and the spawn-level timeout guards the push itself. 2. The protected-branch and happy-path regression tests called pushRepoDirectly without {initPush:true}, so they exercised the ordinary unguarded factory and could not detect removal of the init-specific subprocess timeout. Both now pass {initPush:true}. autoPushViaMR is no longer called from init; it remains for the import flow in push.ts. Co-Authored-By: Claude Code --- src/__tests__/init-hang-regression.test.ts | 4 +- src/init.ts | 44 ++++++++++------------ 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/src/__tests__/init-hang-regression.test.ts b/src/__tests__/init-hang-regression.test.ts index b72358ae9..a10415cc7 100644 --- a/src/__tests__/init-hang-regression.test.ts +++ b/src/__tests__/init-hang-regression.test.ts @@ -92,7 +92,7 @@ describe('createGit hang guards (init-hang regression)', () => { const start = Date.now(); await expect( - pushRepoDirectly(clone, '[teamai] Register member: alice', ['members/alice.yaml']), + pushRepoDirectly(clone, '[teamai] Register member: alice', ['members/alice.yaml'], { initPush: true }), ).rejects.toThrow(); const elapsed = Date.now() - start; @@ -125,7 +125,7 @@ describe('createGit hang guards (init-hang regression)', () => { // No timeout, no throw — the guarded instance completes a normal push. await expect( - pushRepoDirectly(clone, '[teamai] Register member: bob', ['members/bob.yaml']), + pushRepoDirectly(clone, '[teamai] Register member: bob', ['members/bob.yaml'], { initPush: true }), ).resolves.toBeUndefined(); }); }); diff --git a/src/init.ts b/src/init.ts index ffa3aef13..3d313f30a 100644 --- a/src/init.ts +++ b/src/init.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { saveLocalConfig, loadTeamConfig, saveLocalConfigForScope, loadLocalConfigForScope, loadStateForScope, saveStateForScope, resolveProjectDataHome } from './config.js'; import { reconcileTeamHooksForConfig } from './hooks.js'; -import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward, pushRepoDirectly, autoPushViaMR, initPushBlockTimeoutMs } from './utils/git.js'; +import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward, pushRepoDirectly, initPushBlockTimeoutMs } from './utils/git.js'; import { withTimeout } from './utils/async.js'; import { getProvider, detectProviderForInit, RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './providers/index.js'; import { parseGenericGitExistingRemote } from './providers/git/repo-url.js'; @@ -1521,30 +1521,24 @@ export async function init(options: GlobalOptions & { if (!options.dryRun) { try { - const mrTeamConfig = await loadTeamConfig(localPath); - const mrLocalConfig = { - repo: { remote: repoInfo.httpsUrl, localPath }, - username, - }; - if (mrTeamConfig) { - const prUrl = await withTimeout( - autoPushViaMR( - localPath, - `[teamai] Configure reviewers: ${reviewers.join(', ')}`, - ['teamai.yaml'], - mrTeamConfig, - mrLocalConfig, - { initPush: true }, - ), - initPushBlockTimeoutMs(), - 'Reviewer config push', - ); - if (prUrl) { - log.success(`Reviewer config pushed via MR: ${prUrl}`); - } else { - log.warn('Reviewer config MR could not be created (you can push manually later)'); - } - } + // Reviewer config is part of teamai.yaml — it belongs on the + // default branch, not behind an MR. Use pushRepoDirectly with + // the init-push timeout guard. (autoPushViaMR is not used here: + // it shells out to a provider's `pr create` CLI via spawnSync, + // which blocks the event loop so withTimeout's timer can never + // fire — and the generic git provider has no PR API anyway, so + // MR-only routing would regress it. See PR #677 review.) + await withTimeout( + pushRepoDirectly( + localPath, + `[teamai] Configure reviewers: ${reviewers.join(', ')}`, + ['teamai.yaml'], + { initPush: true }, + ), + initPushBlockTimeoutMs(), + 'Reviewer config push', + ); + log.success('Reviewer config pushed to team repo'); } catch (e) { log.warn(`Push failed (you can push manually later): ${(e as Error).message}`); } From 82fbe7262edd0305b99c2ee17cc381875f90f647 Mon Sep 17 00:00:00 2001 From: irootech Date: Tue, 22 Sep 2026 15:50:01 +0800 Subject: [PATCH 8/8] fix(init): bound MR-creation subprocess + validate timeout env + cut init retries Address round-6 review on PR #677: 1. Reviewer config goes via autoPushViaMR again (a protected default branch rejects a direct push). The stalled-MR-creation risk from round-5 is fixed at the root: PrCreateOptions gains spawnTimeoutMs, threaded through createPrWithFallback -> autoPushViaMR (uses initPushBlockTimeoutMs) -> github ghExec / tgit gfExec, which pass it to spawnSync/crossSpawn.sync's timeout option. spawnSync blocks the event loop so withTimeout cannot interrupt it; the spawnSync timeout kills the process at the OS level, bounding init for real. 2. updateReports' push/fetch/rebase retry loop could run for several timeout periods (5 retries) even after init's withTimeout fired. Init pushes now bound retries to 1 (INIT_PUSH_MAX_RETRIES): init is non-blocking and retries on the next run, so a stuck remote no longer holds init for 5x the timeout. 3. TEAMAI_INIT_PUSH_TIMEOUT_MS accepted -1 / 100abc. Validate as a finite positive integer, falling back to 30s otherwise. Co-Authored-By: Claude Code --- src/init.ts | 50 +++++++++++++++++++++------------- src/providers/github/gh-cli.ts | 10 +++++-- src/providers/github/index.ts | 1 + src/providers/tgit/gf-cli.ts | 10 +++++-- src/providers/tgit/index.ts | 1 + src/providers/types.ts | 8 ++++++ src/push.ts | 2 ++ src/utils/branch-worktree.ts | 10 +++++-- src/utils/git.ts | 14 +++++++++- 9 files changed, 80 insertions(+), 26 deletions(-) diff --git a/src/init.ts b/src/init.ts index 3d313f30a..21df75666 100644 --- a/src/init.ts +++ b/src/init.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { saveLocalConfig, loadTeamConfig, saveLocalConfigForScope, loadLocalConfigForScope, loadStateForScope, saveStateForScope, resolveProjectDataHome } from './config.js'; import { reconcileTeamHooksForConfig } from './hooks.js'; -import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward, pushRepoDirectly, initPushBlockTimeoutMs } from './utils/git.js'; +import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward, pushRepoDirectly, autoPushViaMR, initPushBlockTimeoutMs } from './utils/git.js'; import { withTimeout } from './utils/async.js'; import { getProvider, detectProviderForInit, RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './providers/index.js'; import { parseGenericGitExistingRemote } from './providers/git/repo-url.js'; @@ -1521,24 +1521,36 @@ export async function init(options: GlobalOptions & { if (!options.dryRun) { try { - // Reviewer config is part of teamai.yaml — it belongs on the - // default branch, not behind an MR. Use pushRepoDirectly with - // the init-push timeout guard. (autoPushViaMR is not used here: - // it shells out to a provider's `pr create` CLI via spawnSync, - // which blocks the event loop so withTimeout's timer can never - // fire — and the generic git provider has no PR API anyway, so - // MR-only routing would regress it. See PR #677 review.) - await withTimeout( - pushRepoDirectly( - localPath, - `[teamai] Configure reviewers: ${reviewers.join(', ')}`, - ['teamai.yaml'], - { initPush: true }, - ), - initPushBlockTimeoutMs(), - 'Reviewer config push', - ); - log.success('Reviewer config pushed to team repo'); + // Reviewer config goes via MR: a protected default branch rejects + // a direct push (the original bug), and an MR lands it on a + // feature branch for a maintainer to merge. The push itself uses + // createGitForInitPush (spawn-level timeout); the provider's + // pr-create spawnSync also carries the init-push timeout so a + // stalled gh/gf process cannot block the event loop past it. + const mrTeamConfig = await loadTeamConfig(localPath); + const mrLocalConfig = { + repo: { remote: repoInfo.httpsUrl, localPath }, + username, + }; + if (mrTeamConfig) { + const prUrl = await withTimeout( + autoPushViaMR( + localPath, + `[teamai] Configure reviewers: ${reviewers.join(', ')}`, + ['teamai.yaml'], + mrTeamConfig, + mrLocalConfig, + { initPush: true }, + ), + initPushBlockTimeoutMs(), + 'Reviewer config push', + ); + if (prUrl) { + log.success(`Reviewer config pushed via MR: ${prUrl}`); + } else { + log.warn('Reviewer config MR could not be created (you can push manually later)'); + } + } } catch (e) { log.warn(`Push failed (you can push manually later): ${(e as Error).message}`); } diff --git a/src/providers/github/gh-cli.ts b/src/providers/github/gh-cli.ts index 2d5a5c9ff..3e6a092ce 100644 --- a/src/providers/github/gh-cli.ts +++ b/src/providers/github/gh-cli.ts @@ -47,7 +47,7 @@ export function isGhInstalled(): boolean { */ export function ghExec( args: string[], - options?: { inheritStdio?: boolean; cwd?: string; env?: NodeJS.ProcessEnv }, + options?: { inheritStdio?: boolean; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }, ): { stdout: string; stderr: string; status: number } { const ghPath = getGhPath(); if (!ghPath) { @@ -63,6 +63,7 @@ export function ghExec( stdio: 'inherit', env: { ...process.env, ...(options.env ?? {}) }, cwd: options.cwd, + ...(options.timeoutMs ? { timeout: options.timeoutMs } : {}), }); return { stdout: '', stderr: '', status: result.status ?? 1 }; } @@ -72,6 +73,7 @@ export function ghExec( encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, cwd: options?.cwd, + ...(options?.timeoutMs ? { timeout: options.timeoutMs } : {}), }); return { @@ -338,6 +340,10 @@ export interface GhPrCreateOptions { reviewers?: string[]; /** Working directory (the team repo local path) */ cwd?: string; + /** Hard timeout (ms) for the `gh pr create` subprocess; kills it at the OS + * level once exceeded. spawnSync blocks the event loop, so withTimeout cannot + * interrupt it — this is the only way to bound a stalled PR creation. */ + spawnTimeoutMs?: number; } /** @@ -384,7 +390,7 @@ function ghPrCreateViaCli(opts: GhPrCreateOptions): string { args.push('-r', opts.reviewers.join(',')); } - const result = ghExec(args, { cwd: opts.cwd }); + const result = ghExec(args, { cwd: opts.cwd, ...(opts.spawnTimeoutMs ? { timeoutMs: opts.spawnTimeoutMs } : {}) }); if (result.status !== 0) { const errMsg = result.stderr || result.stdout; throw new Error(`gh pr create failed: ${errMsg}`); diff --git a/src/providers/github/index.ts b/src/providers/github/index.ts index 3f623f771..9d7af0342 100644 --- a/src/providers/github/index.ts +++ b/src/providers/github/index.ts @@ -60,6 +60,7 @@ export class GitHubProvider implements GitProvider { description: opts.description, reviewers: opts.reviewers, cwd: opts.cwd, + spawnTimeoutMs: opts.spawnTimeoutMs, }); } diff --git a/src/providers/tgit/gf-cli.ts b/src/providers/tgit/gf-cli.ts index 964fec165..85c60578d 100644 --- a/src/providers/tgit/gf-cli.ts +++ b/src/providers/tgit/gf-cli.ts @@ -37,7 +37,7 @@ function shellQuote(s: string): string { */ export function gfExec( args: string[], - options?: { inheritStdio?: boolean; cwd?: string }, + options?: { inheritStdio?: boolean; cwd?: string; timeoutMs?: number }, ): { stdout: string; stderr: string; status: number } { const gfPath = getGfPath(); // Shell-quote every token (including the binary path) so values such as repo @@ -52,6 +52,7 @@ export function gfExec( stdio: 'inherit', env: { ...process.env }, cwd: options.cwd, + ...(options.timeoutMs ? { timeout: options.timeoutMs } : {}), }); return { stdout: '', stderr: '', status: result.status ?? 1 }; } @@ -61,6 +62,7 @@ export function gfExec( encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, cwd: options?.cwd, + ...(options?.timeoutMs ? { timeout: options.timeoutMs } : {}), }); return { @@ -397,6 +399,10 @@ export interface GfMrCreateOptions { reviewers?: string[]; /** Working directory for gf CLI (should be the team repo path) */ cwd?: string; + /** Hard timeout (ms) for the `gf mr create` subprocess; kills it at the OS + * level once exceeded. spawnSync blocks the event loop, so withTimeout cannot + * interrupt it — this bounds a stalled MR creation. */ + spawnTimeoutMs?: number; } /** @@ -420,7 +426,7 @@ export function gfMrCreate(opts: GfMrCreateOptions): string { args.push('-r', opts.reviewers.join(',')); } - const result = gfExec(args, { cwd: opts.cwd }); + const result = gfExec(args, { cwd: opts.cwd, ...(opts.spawnTimeoutMs ? { timeoutMs: opts.spawnTimeoutMs } : {}) }); if (result.status !== 0) { const errMsg = result.stderr || result.stdout; throw new Error(`gf mr create failed: ${errMsg}`); diff --git a/src/providers/tgit/index.ts b/src/providers/tgit/index.ts index 03b8f1452..cab62d4e1 100644 --- a/src/providers/tgit/index.ts +++ b/src/providers/tgit/index.ts @@ -61,6 +61,7 @@ export class TGitProvider implements GitProvider { description: opts.description, reviewers: opts.reviewers, cwd: opts.cwd, + spawnTimeoutMs: opts.spawnTimeoutMs, }); } diff --git a/src/providers/types.ts b/src/providers/types.ts index e67b68adb..ccf95ab8b 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -38,6 +38,14 @@ export interface PrCreateOptions { reviewers?: string[]; /** Working directory for CLI operations */ cwd?: string; + /** + * Hard timeout (ms) for any synchronous provider CLI subprocess (e.g. + * `gh pr create`, `gf mr`). When set, the spawned process is killed at the + * OS level once it exceeds this — a spawnSync blocks the event loop, so a + * plain withTimeout cannot interrupt it; this is the only way to guarantee + * a stalled MR creation cannot hold init hostage. + */ + spawnTimeoutMs?: number; } /** diff --git a/src/push.ts b/src/push.ts index a6001f591..d1e0370ef 100644 --- a/src/push.ts +++ b/src/push.ts @@ -92,6 +92,7 @@ async function createPrWithFallback( branchName: string, title: string, description: string, + opts: { spawnTimeoutMs?: number } = {}, ): Promise { const provider = getProvider(teamConfig.provider); const mrSpin = spinner('Creating Pull Request...').start(); @@ -114,6 +115,7 @@ async function createPrWithFallback( description, reviewers: teamConfig.reviewers?.length ? teamConfig.reviewers : undefined, cwd: localConfig.repo.localPath, + spawnTimeoutMs: opts.spawnTimeoutMs, }); mrSpin.succeed(`Pull Request created: ${prUrl}`); return prUrl; diff --git a/src/utils/branch-worktree.ts b/src/utils/branch-worktree.ts index 007c80ce9..1d9789e2c 100644 --- a/src/utils/branch-worktree.ts +++ b/src/utils/branch-worktree.ts @@ -290,6 +290,11 @@ async function writeWorktreeGitignore(wt: string): Promise { } const MAX_PUSH_RETRIES = 5; +// Init pushes are non-blocking (init completes and writes local config + skills +// regardless); a failed push is retried on the next init. Bounding retries to 1 +// during init prevents a stuck remote from holding init for 5× the timeout via +// the push/fetch/rebase retry loop. +const INIT_PUSH_MAX_RETRIES = 1; export interface BranchWrite { files: string[]; @@ -331,7 +336,8 @@ async function commitAndPushAt( // Push with fetch+rebase retry. Each member only writes .yaml, so // rebase conflicts are effectively impossible; retries handle the pure // non-fast-forward race. - for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) { + const maxRetries = options.initPush ? INIT_PUSH_MAX_RETRIES : MAX_PUSH_RETRIES; + for (let attempt = 1; attempt <= maxRetries; attempt++) { try { // A push that resolves is a push the remote accepted: git exits non-zero // when it refuses one. Do NOT re-check the remote-tracking ref here — it @@ -340,7 +346,7 @@ async function commitAndPushAt( await git.push(['origin', spec.branch]); return { status: 'published' }; } catch (pushErr) { - if (attempt === MAX_PUSH_RETRIES) { + if (attempt === maxRetries) { log.debug(`[${spec.logTag}] push failed after ${attempt} attempts: ${(pushErr as Error).message}`); return { status: 'failed', reason: (pushErr as Error).message }; } diff --git a/src/utils/git.ts b/src/utils/git.ts index 32daf85ff..0e87b796f 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -44,9 +44,20 @@ export function disableGitTerminalPrompt(): void { * legitimate slow clones/fetches/rebases in unrelated commands. The env var * lets a slow link or a very large team repo raise the ceiling without a new * release. Read at call time (not module load) so tests can override it. + * + * Validates the env var: a non-numeric, negative, or non-finite value falls + * back to the default rather than reaching simple-git (which would either + * silently disable the timeout or misbehave). */ export function initPushBlockTimeoutMs(): number { - return Number.parseInt(process.env.TEAMAI_INIT_PUSH_TIMEOUT_MS ?? '', 10) || 30_000; + const raw = process.env.TEAMAI_INIT_PUSH_TIMEOUT_MS; + if (raw !== undefined) { + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return 30_000; } /** @@ -500,6 +511,7 @@ export async function autoPushViaMR( const { createPrWithFallback } = await import('../push.js'); const prUrl = await createPrWithFallback( teamConfig, localConfig, branchName, message, message, + opts.initPush ? { spawnTimeoutMs: initPushBlockTimeoutMs() } : {}, ); await checkoutMaster(repoPath);