Skip to content
7 changes: 7 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1787,6 +1787,13 @@ teamai init --repo https://github.com/yourorg/yourrepo --scope user --role <role
teamai pull
```

### Environment variables

| Variable | Purpose | Default |
|---|---|---|
| `TEAMAI_INIT_PUSH_TIMEOUT_MS` | Per-subprocess timeout (ms) for git operations during `teamai init` pushes (member registration, reviewer-config MR, empty-repo skeleton). Kills a hung git child at the process level; also bounds the outer await. Raise it on a slow link or a very large team repo. | `30000` |
| `GIT_TERMINAL_PROMPT` | Set to `0` by the CLI at startup so a push with missing credentials fails fast instead of hanging on an invisible prompt. Set `1` explicitly to restore interactive prompts. | `0` (set by teamai) |

---

## FAQ
Expand Down
7 changes: 7 additions & 0 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1735,6 +1735,13 @@ teamai init --repo https://github.com/yourorg/yourrepo --scope user --role <role
teamai pull
```

### 环境变量

| 变量 | 用途 | 默认值 |
|---|---|---|
| `TEAMAI_INIT_PUSH_TIMEOUT_MS` | `teamai init` 推送期间(成员注册、reviewer 配置 MR、空仓骨架)每个 git 子进程的超时(毫秒)。在进程级杀掉挂起的 git 子进程,同时约束外层 await。网络慢或团队仓很大时调高。 | `30000` |
| `GIT_TERMINAL_PROMPT` | CLI 启动时置为 `0`,缺凭据的 push 会快速失败,而不是在看不见的提示符上挂起。显式设为 `1` 可恢复交互式提示。 | `0`(由 teamai 设置) |

---

## 常见问题 FAQ
Expand Down
86 changes: 84 additions & 2 deletions src/__tests__/git.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -20,10 +20,19 @@ 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
// 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', () => ({
Expand Down Expand Up @@ -55,6 +64,79 @@ 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, 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. 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('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('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;
}
});
});

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('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');
});
});

describe('generateBranchName', () => {
it('should produce teamai/push/<username>/<timestamp> format', () => {
Expand Down
235 changes: 235 additions & 0 deletions src/__tests__/init-hang-regression.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
/**
* 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 http from 'node:http';
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<string> {
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'], { initPush: true }),
).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'], { initPush: true }),
).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<void>((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<void>((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);
});
2 changes: 2 additions & 0 deletions src/__tests__/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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 `<repo>/.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).
Expand Down
Loading
Loading