diff --git a/docs/usage-guide.md b/docs/usage-guide.md index e6f430ff9..6e6814383 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -766,7 +766,7 @@ teamai push --role pm # Push into the pm namespace (skills/pm/, rules/pm/, agen teamai push --branch feature/gitee-destination # Use an explicit destination branch ``` -`--branch` names the branch that receives a new push; an existing open PR is always updated on its recorded branch. TeamAI refuses to start a push when the team-repo clone has user changes (modified, staged, untracked, or conflicted files); TeamAI-owned `teamai.yaml` and sync-lock state are handled separately. Commit or stash other local changes first. +`--branch` names the branch that receives a new push; an existing open PR is always updated on its recorded branch. TeamAI refuses to start a push when the team-repo clone has user changes (modified, staged, untracked, or conflicted files); TeamAI-owned `teamai.yaml`, the env files `teamai env add` edited, and sync-lock state are handled separately. Commit or stash other local changes first. **Namespace selection (new resources):** When pushing a new skill, rule or agent, the CLI automatically detects available namespaces and offers an interactive choice: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 50d79ac2b..700907d68 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -695,7 +695,7 @@ teamai push --role pm # 推送到 pm namespace(skills/pm/、rules/pm/、agent teamai push --branch feature/gitee-destination # 使用显式目标分支 ``` -`--branch` 指定新推送使用的分支;已有开放 PR 始终沿用其记录的分支进行更新。如果团队仓库 clone 存在用户修改、暂存、未跟踪或冲突文件,TeamAI 会在 push 前拒绝执行;TeamAI 自己管理的 `teamai.yaml` 和 sync-lock 状态会单独处理。其他本地改动请先提交或 stash。 +`--branch` 指定新推送使用的分支;已有开放 PR 始终沿用其记录的分支进行更新。如果团队仓库 clone 存在用户修改、暂存、未跟踪或冲突文件,TeamAI 会在 push 前拒绝执行;TeamAI 自己管理的 `teamai.yaml`、`teamai env add` 修改的 env 文件和 sync-lock 状态会单独处理。其他本地改动请先提交或 stash。 **命名空间选择(新资源):** 推送新的 skill、rule 或 agent 时,CLI 会自动检测可用的命名空间并提供交互式选择: diff --git a/src/__tests__/push-env.test.ts b/src/__tests__/push-env.test.ts new file mode 100644 index 000000000..2dead29b1 --- /dev/null +++ b/src/__tests__/push-env.test.ts @@ -0,0 +1,467 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { simpleGit } from 'simple-git'; +import { askSelection } from '../utils/prompt.js'; + +// Regression for #881: `env add` edits env/env.yaml in a standalone team clone +// without committing and leaves the commit to `push`. The #690 dirty-clone +// guard refused that file, so the edit never got published. Drives the real +// envAdd() and push() against a bare "remote" and a working clone, mocking only +// config detection and the provider's PR creation. + +const mockCreatePullRequest = vi.fn().mockResolvedValue('https://example.test/pr/1'); +const mockAutoDetectInit = vi.fn(); +const mockDetectProjectConfig = vi.fn(); +const freshState = (): unknown => ({ lastPush: null, pushedSkills: [], pushedRules: [], pushedEnvVars: [] }); +let storedState = freshState(); + +vi.mock('../providers/index.js', () => ({ + getProvider: () => ({ + name: 'github', + parseRepoInput: (input: string) => ({ owner: 'acme', repo: 'team', httpsUrl: input }), + createPullRequest: (...args: unknown[]) => mockCreatePullRequest(...args), + }), +})); + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + autoDetectInit: (...args: unknown[]) => mockAutoDetectInit(...args), + detectProjectConfig: (...args: unknown[]) => mockDetectProjectConfig(...args), + // One state file per test, so a second push sees the first one's records. + loadStateForScope: vi.fn(() => Promise.resolve(structuredClone(storedState))), + saveStateForScope: vi.fn((state: unknown) => { + storedState = structuredClone(state); + return Promise.resolve(); + }), +})); + +// Path → writes still allowed before each further write fails, to stand for +// a disk that refuses the restore. +const failingWrites = new Map(); +vi.mock('../utils/fs.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeFile: (filePath: string, content: string) => { + const allowed = failingWrites.get(filePath); + if (allowed === undefined) return actual.writeFile(filePath, content); + if (allowed > 0) { + failingWrites.set(filePath, allowed - 1); + return actual.writeFile(filePath, content); + } + return Promise.reject(new Error(`EACCES: permission denied, open '${filePath}'`)); + }, + }; +}); + +vi.mock('../read-only.js', () => ({ assertNotReadOnly: vi.fn() })); +vi.mock('../utils/pre-push-sync.js', () => ({ syncTeamUpdatesToLocal: vi.fn() })); +vi.mock('../utils/prompt.js', () => ({ + isInteractive: vi.fn(() => true), + askQuestion: vi.fn(() => Promise.resolve('')), + askConfirmation: vi.fn(() => Promise.resolve(true)), + askSelection: vi.fn((_p: string, n: number, all?: boolean) => + Promise.resolve(all ? Array.from({ length: n }, (_x, i) => i) : null)), + parseSelection: vi.fn(), + closePrompt: vi.fn(), +})); + +async function initTeamRepos(root: string): Promise<{ teamRepo: string; remote: string }> { + const remote = path.join(root, 'remote.git'); + const seed = path.join(root, 'seed'); + const teamRepo = path.join(root, 'team-repo'); + await simpleGit().init(['--bare', '--initial-branch=main', remote]); + + fs.mkdirSync(path.join(seed, 'env', 'team'), { recursive: true }); + fs.writeFileSync(path.join(seed, 'teamai.yaml'), 'version: 1\n'); + fs.writeFileSync(path.join(seed, 'README.md'), '# team\n'); + fs.writeFileSync(path.join(seed, 'env', 'env.yaml'), 'variables:\n - key: TEAM_VAR\n value: first\n'); + fs.writeFileSync(path.join(seed, 'env', 'team', 'env.yaml'), 'variables:\n - key: TEAM_ONLY\n value: first\n'); + const seedGit = simpleGit(seed); + await seedGit.init(); + await seedGit.addConfig('user.email', 't@t.com'); + await seedGit.addConfig('user.name', 't'); + await seedGit.add('.'); + await seedGit.commit('init'); + await seedGit.branch(['-M', 'main']); + await seedGit.addRemote('origin', remote); + await seedGit.push(['-u', 'origin', 'main']); + + await simpleGit().clone(remote, teamRepo); + const trGit = simpleGit(teamRepo); + await trGit.addConfig('user.email', 't@t.com'); + await trGit.addConfig('user.name', 't'); + return { teamRepo, remote }; +} + +/** What log.error printed. */ +function errorOutput(): string { + return vi.mocked(console.error).mock.calls.map((args) => args.map(String).join(' ')).join('\n'); +} + +/** What push printed to stderr, where the spinner reports the refusal. */ +function stderrOutput(): string { + return vi.mocked(process.stderr.write).mock.calls.map(([chunk]) => String(chunk)).join(''); +} + +async function pushBranches(remote: string): Promise { + const out = await simpleGit(remote).raw(['for-each-ref', '--format=%(refname:short)', 'refs/heads/teamai/']); + return out.split('\n').filter(Boolean); +} + +describe('push publishes the env files env add leaves in a standalone clone (#881)', () => { + let tmpDir: string; + let teamRepo: string; + let remote: string; + let previousExitCode: typeof process.exitCode; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-push-env-')); + vi.clearAllMocks(); + failingWrites.clear(); + storedState = freshState(); + mockCreatePullRequest.mockResolvedValue('https://example.test/pr/1'); + ({ teamRepo, remote } = await initTeamRepos(tmpDir)); + const localConfig = { + repo: { localPath: teamRepo, remote }, + username: 'alice', + scope: 'user', + }; + mockDetectProjectConfig.mockResolvedValue(localConfig); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig: { repo: 'acme/team', toolPaths: {} } }); + previousExitCode = process.exitCode; + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = previousExitCode; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('pushes the env.yaml edit from env add', async () => { + const { envAdd } = await import('../env-commands.js'); + const { push } = await import('../push.js'); + await envAdd('TEAM_VAR', 'changed', {}); + expect(await simpleGit(teamRepo).raw(['status', '--porcelain'])).toContain('env/env.yaml'); + + await push({ all: true }); + + expect(process.exitCode).toBe(previousExitCode); + expect(mockCreatePullRequest).toHaveBeenCalledTimes(1); + const [branch] = await pushBranches(remote); + expect(branch).toBeDefined(); + const pushed = await simpleGit(remote).show([`${branch}:env/env.yaml`]); + expect(pushed).toContain('value: changed'); + }); + + it('keeps the env.yaml edit when the refresh fails after the reset', async () => { + const { envAdd } = await import('../env-commands.js'); + const { push } = await import('../push.js'); + await envAdd('TEAM_VAR', 'changed', {}); + await simpleGit(teamRepo).remote(['set-url', 'origin', path.join(tmpDir, 'missing.git')]); + + await push({ all: true }); + + expect(stderrOutput()).toContain('Pull failed'); + expect(fs.readFileSync(path.join(teamRepo, 'env', 'env.yaml'), 'utf8')).toContain('value: changed'); + }); + + it('still refuses when another path is dirty, and keeps the env edit', async () => { + const { envAdd } = await import('../env-commands.js'); + const { push } = await import('../push.js'); + await envAdd('TEAM_VAR', 'changed', {}); + fs.appendFileSync(path.join(teamRepo, 'README.md'), 'local note\n'); + + await push({ all: true }); + + expect(process.exitCode).toBe(1); + const errors = stderrOutput(); + expect(errors).toContain('Cannot push: the team repo has uncommitted changes'); + expect(errors).toMatch(/Paths: README\.md$/m); + expect(mockCreatePullRequest).not.toHaveBeenCalled(); + expect(await pushBranches(remote)).toEqual([]); + expect(fs.readFileSync(path.join(teamRepo, 'README.md'), 'utf8')).toContain('local note'); + expect(fs.readFileSync(path.join(teamRepo, 'env', 'env.yaml'), 'utf8')).toContain('value: changed'); + }); + + it('still refuses an env.yaml edit staged before a later edit, and keeps both', async () => { + const { envAdd } = await import('../env-commands.js'); + const { push } = await import('../push.js'); + const git = simpleGit(teamRepo); + const envPath = path.join(teamRepo, 'env', 'env.yaml'); + await envAdd('TEAM_VAR', 'staged', {}); + await git.add('env/env.yaml'); + // Edited by hand: a second `env add` would realign the clone first. + fs.writeFileSync(envPath, fs.readFileSync(envPath, 'utf8').replace('value: staged', 'value: changed')); + + await push({ all: true }); + + expect(process.exitCode).toBe(1); + expect(stderrOutput()).toMatch(/Paths: env\/env\.yaml$/m); + expect(await pushBranches(remote)).toEqual([]); + expect(await git.show([':env/env.yaml'])).toContain('value: staged'); + expect(fs.readFileSync(path.join(teamRepo, 'env', 'env.yaml'), 'utf8')).toContain('value: changed'); + }); + + it('keeps the env.yaml edit and its mode when the push rolls the clone back', async () => { + const { envAdd } = await import('../env-commands.js'); + const { push } = await import('../push.js'); + const envPath = path.join(teamRepo, 'env', 'env.yaml'); + await envAdd('TEAM_VAR', 'changed', {}); + // Git sees no mode change in 0600, but reset --hard recreates the file 0644. + fs.chmodSync(envPath, 0o600); + // A local branch of the requested name makes the branch creation throw + // after the copy step, so pushGroup resets and cleans the clone. + await simpleGit(teamRepo).branch(['teamai/taken']); + + await push({ all: true, branch: 'teamai/taken' }); + + expect(process.exitCode).toBe(1); + expect(stderrOutput()).toContain('Push failed'); + expect(fs.readFileSync(envPath, 'utf8')).toContain('value: changed'); + expect(fs.statSync(envPath).mode & 0o777).toBe(0o600); + }); + + it('pushes only the selected env file and keeps the deselected edit in the clone', async () => { + const { push } = await import('../push.js'); + const git = simpleGit(teamRepo); + const rootEnv = path.join(teamRepo, 'env', 'env.yaml'); + const teamEnv = path.join(teamRepo, 'env', 'team', 'env.yaml'); + fs.writeFileSync(rootEnv, fs.readFileSync(rootEnv, 'utf8').replace('value: first', 'value: selected')); + fs.writeFileSync(teamEnv, fs.readFileSync(teamEnv, 'utf8').replace('value: first', 'value: deselected')); + // Entry files list the root first: 1. env.yaml, 2. team/env.yaml. + vi.mocked(askSelection).mockResolvedValueOnce([0]); + + await push({}); + + const [branch] = await pushBranches(remote); + expect(branch).toBeDefined(); + expect(await simpleGit(remote).show([`${branch}:env/env.yaml`])).toContain('value: selected'); + expect(await simpleGit(remote).show([`${branch}:env/team/env.yaml`])).toContain('value: first'); + expect(fs.readFileSync(teamEnv, 'utf8')).toContain('value: deselected'); + expect(await git.raw(['status', '--porcelain'])).toContain('env/team/env.yaml'); + }); + + it('keeps the env.yaml edit on the default branch when git push fails after the commit, and retries it', async () => { + const { envAdd } = await import('../env-commands.js'); + const { push } = await import('../push.js'); + const git = simpleGit(teamRepo); + const hook = path.join(remote, 'hooks', 'pre-receive'); + fs.writeFileSync(hook, '#!/bin/sh\necho rejected >&2\nexit 1\n', { mode: 0o755 }); + await envAdd('TEAM_VAR', 'changed', {}); + + await push({ all: true }); + + expect(process.exitCode).toBe(1); + expect(stderrOutput()).toContain('Push failed'); + expect((await git.revparse(['--abbrev-ref', 'HEAD'])).trim()).toBe('main'); + expect(await git.raw(['status', '--porcelain'])).toContain('env/env.yaml'); + expect(fs.readFileSync(path.join(teamRepo, 'env', 'env.yaml'), 'utf8')).toContain('value: changed'); + + fs.rmSync(hook); + process.exitCode = previousExitCode; + // A generated name could repeat the failed run's, which is still a local branch. + await push({ all: true, branch: 'teamai/retry' }); + + const [branch] = await pushBranches(remote); + expect(branch).toBeDefined(); + expect(await simpleGit(remote).show([`${branch}:env/env.yaml`])).toContain('value: changed'); + }); + + it('restores a new env file env add --role created when the push rolls the clone back', async () => { + const { envAdd } = await import('../env-commands.js'); + const { push } = await import('../push.js'); + const git = simpleGit(teamRepo); + const opsEnv = path.join(teamRepo, 'env', 'ops', 'env.yaml'); + await envAdd('OPS_VAR', 'ops-value', { role: 'ops' }); + expect(await git.raw(['status', '--porcelain', '--untracked-files=all'])).toContain('?? env/ops/env.yaml'); + // Git records no mode for an untracked file, so only the snapshot can keep it. + fs.chmodSync(opsEnv, 0o600); + await git.branch(['teamai/taken']); + + await push({ all: true, branch: 'teamai/taken' }); + + expect(process.exitCode).toBe(1); + expect(stderrOutput()).toContain('Push failed'); + expect(fs.readFileSync(opsEnv, 'utf8')).toContain('value: ops-value'); + expect(fs.statSync(opsEnv).mode & 0o777).toBe(0o600); + expect(await git.raw(['status', '--porcelain', '--untracked-files=all'])).toContain('?? env/ops/env.yaml'); + }); + + it('restores a new env file env add --role created when git push fails after the commit', async () => { + const { envAdd } = await import('../env-commands.js'); + const { push } = await import('../push.js'); + const git = simpleGit(teamRepo); + const opsEnv = path.join(teamRepo, 'env', 'ops', 'env.yaml'); + fs.writeFileSync(path.join(remote, 'hooks', 'pre-receive'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + await envAdd('OPS_VAR', 'ops-value', { role: 'ops' }); + fs.chmodSync(opsEnv, 0o600); + + await push({ all: true }); + + expect(process.exitCode).toBe(1); + expect((await git.revparse(['--abbrev-ref', 'HEAD'])).trim()).toBe('main'); + expect(fs.readFileSync(opsEnv, 'utf8')).toContain('value: ops-value'); + expect(fs.statSync(opsEnv).mode & 0o777).toBe(0o600); + expect(await git.raw(['status', '--porcelain', '--untracked-files=all'])).toContain('?? env/ops/env.yaml'); + }); + + it('keeps the teamai.yaml edit on the default branch when git push fails after the commit', async () => { + const { envAdd } = await import('../env-commands.js'); + const { push } = await import('../push.js'); + const git = simpleGit(teamRepo); + fs.writeFileSync(path.join(remote, 'hooks', 'pre-receive'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + await envAdd('TEAM_VAR', 'changed', {}); + // After env add, whose refresh would realign the clone and drop it. + fs.appendFileSync(path.join(teamRepo, 'teamai.yaml'), '# local edit\n'); + + await push({ all: true }); + + expect(process.exitCode).toBe(1); + expect((await git.revparse(['--abbrev-ref', 'HEAD'])).trim()).toBe('main'); + expect(fs.readFileSync(path.join(teamRepo, 'teamai.yaml'), 'utf8')).toContain('# local edit'); + expect(fs.readFileSync(path.join(teamRepo, 'env', 'env.yaml'), 'utf8')).toContain('value: changed'); + }); + + it('stops and names the env file when putting it back after the refresh fails', async () => { + const { envAdd } = await import('../env-commands.js'); + const { push } = await import('../push.js'); + await envAdd('TEAM_VAR', 'changed', {}); + failingWrites.set(path.join(teamRepo, 'env', 'env.yaml'), 0); + + await push({ all: true }); + + expect(process.exitCode).toBe(1); + expect(stderrOutput()).not.toContain('Pull failed'); + expect(errorOutput()).toMatch(/env\/env\.yaml \(EACCES: permission denied/); + expect(errorOutput()).toContain('teamai env add'); + expect(mockCreatePullRequest).not.toHaveBeenCalled(); + }); + + it('stops and names the env file when putting it back after a group fails', async () => { + const { envAdd } = await import('../env-commands.js'); + const { push } = await import('../push.js'); + const { saveStateForScope } = await import('../config.js'); + await envAdd('TEAM_VAR', 'changed', {}); + await simpleGit(teamRepo).branch(['teamai/taken']); + // The restore after the refresh succeeds; the one after the rollback fails. + failingWrites.set(path.join(teamRepo, 'env', 'env.yaml'), 1); + + await push({ all: true, branch: 'teamai/taken' }); + + expect(process.exitCode).toBe(1); + expect(stderrOutput()).toContain('Push failed'); + expect(errorOutput()).toMatch(/env\/env\.yaml \(EACCES: permission denied/); + expect(errorOutput()).toContain('teamai env add'); + // Earlier groups' PR records are saved before stopping, as for any failed group. + expect(vi.mocked(saveStateForScope)).toHaveBeenCalled(); + }); + + it('keeps the bytes of an env file that is not UTF-8', async () => { + const { push } = await import('../push.js'); + const envPath = path.join(teamRepo, 'env', 'env.yaml'); + // A hand edit saved as Latin-1: 0xE9 is "é" there and invalid UTF-8. + const latin1 = Buffer.from('variables:\n - key: TEAM_VAR\n value: caf\xe9\n', 'latin1'); + fs.writeFileSync(envPath, latin1); + await simpleGit(teamRepo).branch(['teamai/taken']); + + await push({ all: true, branch: 'teamai/taken' }); + + expect(process.exitCode).toBe(1); + expect(fs.readFileSync(envPath).equals(latin1)).toBe(true); + }); + + it('keeps a deselected env edit when a group with no change rolls the clone back', async () => { + const { push } = await import('../push.js'); + const rootEnv = path.join(teamRepo, 'env', 'env.yaml'); + fs.writeFileSync(rootEnv, fs.readFileSync(rootEnv, 'utf8').replace('value: first', 'value: deselected')); + // A blank line only: pushRepoBranch reads it as metadata, resets and cleans. + fs.appendFileSync(path.join(teamRepo, 'env', 'team', 'env.yaml'), '\n'); + vi.mocked(askSelection).mockResolvedValueOnce([1]); + + await push({}); + + expect(await pushBranches(remote)).toEqual([]); + expect(fs.readFileSync(rootEnv, 'utf8')).toContain('value: deselected'); + }); + + /** Push an env/team/env.yaml edit whose PR creation fails, leaving a reuse record with prUrl null. */ + async function pushWithoutPr(): Promise { + const { push } = await import('../push.js'); + const teamEnv = path.join(teamRepo, 'env', 'team', 'env.yaml'); + fs.writeFileSync(teamEnv, fs.readFileSync(teamEnv, 'utf8').replace('value: first', 'value: reviewed')); + mockCreatePullRequest.mockResolvedValue(null); + await push({ all: true }); + expect(await pushBranches(remote)).toHaveLength(1); + expect(fs.readFileSync(teamEnv, 'utf8')).toContain('value: first'); + process.exitCode = previousExitCode; + vi.mocked(process.stderr.write).mockClear(); + } + + it('keeps a deselected env edit when a reuse group that retries its PR rolls the clone back', async () => { + const { push } = await import('../push.js'); + await pushWithoutPr(); + const rootEnv = path.join(teamRepo, 'env', 'env.yaml'); + fs.writeFileSync(rootEnv, fs.readFileSync(rootEnv, 'utf8').replace('value: first', 'value: deselected')); + // The recorded file now differs from main by a blank line only: the reuse + // branch is rebuilt from main, reads that as metadata, resets and cleans + // the clone, then retries the missing PR. + fs.appendFileSync(path.join(teamRepo, 'env', 'team', 'env.yaml'), '\n'); + vi.mocked(askSelection).mockResolvedValueOnce([1]); + mockCreatePullRequest.mockClear(); + + await push({}); + + expect(mockCreatePullRequest).toHaveBeenCalledTimes(1); + expect(fs.readFileSync(rootEnv, 'utf8')).toContain('value: deselected'); + }); + + it('keeps a deselected env edit when the config-only push after the reuse groups has no change', async () => { + const { push } = await import('../push.js'); + await pushWithoutPr(); + const rootEnv = path.join(teamRepo, 'env', 'env.yaml'); + const teamEnv = path.join(teamRepo, 'env', 'team', 'env.yaml'); + fs.writeFileSync(rootEnv, fs.readFileSync(rootEnv, 'utf8').replace('value: first', 'value: deselected')); + fs.writeFileSync(teamEnv, fs.readFileSync(teamEnv, 'utf8').replace('value: first', 'value: reviewed again')); + // A blank line only: pushTeamConfigOnly's pushRepoBranch resets and cleans. + fs.appendFileSync(path.join(teamRepo, 'teamai.yaml'), '\n'); + vi.mocked(askSelection).mockResolvedValueOnce([1]); + + await push({ branch: 'teamai/config' }); + + expect(stderrOutput()).toContain('No changes to push (config already up to date)'); + expect(fs.readFileSync(rootEnv, 'utf8')).toContain('value: deselected'); + }); + + it('still refuses a deleted env.yaml', async () => { + const { push } = await import('../push.js'); + fs.rmSync(path.join(teamRepo, 'env', 'env.yaml')); + + await push({ all: true }); + + expect(process.exitCode).toBe(1); + expect(stderrOutput()).toContain('Paths: env/env.yaml'); + expect(await pushBranches(remote)).toEqual([]); + }); + + it('still refuses a mode change on env.yaml', async () => { + const { envAdd } = await import('../env-commands.js'); + const { push } = await import('../push.js'); + const git = simpleGit(teamRepo); + await git.addConfig('core.fileMode', 'true'); + await envAdd('TEAM_VAR', 'changed', {}); + fs.chmodSync(path.join(teamRepo, 'env', 'env.yaml'), 0o755); + + await push({ all: true }); + + expect(process.exitCode).toBe(1); + expect(stderrOutput()).toContain('Paths: env/env.yaml'); + expect(await pushBranches(remote)).toEqual([]); + expect(fs.readFileSync(path.join(teamRepo, 'env', 'env.yaml'), 'utf8')).toContain('value: changed'); + }); +}); diff --git a/src/push.ts b/src/push.ts index 0ad93fd72..c8b8f1fa3 100644 --- a/src/push.ts +++ b/src/push.ts @@ -1,3 +1,4 @@ +import { chmod, readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import YAML from 'yaml'; import { autoDetectInit, loadStateForScope, saveStateForScope } from './config.js'; @@ -34,11 +35,11 @@ import { pathExists, pruneEmptyDirs, readFileSafe, writeFile } from './utils/fs. import { brokenTeamProfileFiles } from './models/profile.js'; /** - * Filter a list of repo-root-relative paths (e.g. "rules/", "env/") down to + * Filter a list of repo-root-relative paths (e.g. "rules/", ".codebuddy-plugin/") down to * those that actually exist on disk. `git add` throws `pathspec did not match * any files` when any argument doesn't exist, so we guard against that when * passing "sweeper" directories that may or may not be present in a given - * team repo (e.g. a pure-wiki team has no rules/ or env/). + * team repo (e.g. a pure-wiki team has no rules/). */ export async function filterExistingTopLevelPaths( repoPath: string, @@ -342,17 +343,31 @@ async function hasGitModeChange( } } +/** A file's bytes and permission bits, or null when it cannot be read. */ +async function readFileSnapshot(filePath: string): Promise<{ content: Buffer; mode: number } | null> { + try { + const [content, stats] = await Promise.all([readFile(filePath), stat(filePath)]); + return { content, mode: stats.mode & 0o777 }; + } catch { + return null; + } +} + function isTeamaiOwnedDirtyPath( filePath: string, pendingTeamConfig: string | null, modeChangedPaths: ReadonlySet, + pendingEnvPaths: ReadonlySet, ): boolean { const normalized = filePath.replaceAll('\\', '/'); // The sync lock is disposable TeamAI state. teamai.yaml is different: it is // safe to restore only when its content was captured above and its mode is // unchanged. Deletion, mode-only, and content+mode changes must stop before - // reset --hard, or the user's change is silently lost (#690 review). + // reset --hard, or the user's change is silently lost (#690 review). The + // env files `env add` left for push follow the same rule; the caller only + // lists the ones it captured with an unchanged mode (#881). if (normalized === '.teamai/.sync-lock') return true; + if (pendingEnvPaths.has(normalized)) return true; return normalized === 'teamai.yaml' && pendingTeamConfig !== null && !modeChangedPaths.has(normalized); @@ -362,9 +377,10 @@ export function collectUnsafeDirtyPaths( status: PushRepoStatus, pendingTeamConfig: string | null, modeChangedPaths: ReadonlySet = new Set(), + pendingEnvPaths: ReadonlySet = new Set(), ): string[] { return collectDirtyPaths(status) - .filter((filePath) => !isTeamaiOwnedDirtyPath(filePath, pendingTeamConfig, modeChangedPaths)); + .filter((filePath) => !isTeamaiOwnedDirtyPath(filePath, pendingTeamConfig, modeChangedPaths, pendingEnvPaths)); } /** @@ -590,11 +606,12 @@ async function pushGroup(args: { } // Create branch, commit, and push. - // Only include "sweeper" directories (rules/, env/) that actually - // exist — otherwise `git add 'rules/'` throws `pathspec did not match - // any files` and the whole push aborts (BUG #1). A team may not have - // rules/ or env/ yet. - const sweeperCandidates = ['rules/', 'env/', '.codebuddy-plugin/']; + // Only include "sweeper" directories (rules/) that actually exist — + // otherwise `git add 'rules/'` throws `pathspec did not match any files` + // and the whole push aborts (BUG #1). A team may not have rules/ yet. + // env/ is not swept: each env item is its own file in pushedFiles, and a + // sweep would publish the env edits the user left out of the selection (#881). + const sweeperCandidates = ['rules/', '.codebuddy-plugin/']; const existingSweepers = await filterExistingTopLevelPaths( localConfig.repo.localPath, sweeperCandidates, @@ -863,6 +880,36 @@ async function pushCore( // origin/, so resetToCleanMaster/pullRepo (which assume a normal // clone on a branch) are neither needed nor safe — skip them. let pendingTeamConfig: string | null = initialPendingTeamConfig; + // The env edits captured before the refresh below. Their only copy is the + // clone's working tree, so every reset that can run before they are + // committed must be followed by this restore (#881). + // Bytes and permission bits: a hand edit need not be UTF-8, and git records + // no mode for an untracked file (a new env//env.yaml), so clean -fd + // plus a plain write would recreate a 0600 file under the umask. + const pendingEnvFiles = new Map(); + /** Write the captured env edits back; returns each one it could not, with the reason. */ + const restorePendingEnvFiles = async (): Promise => { + const lost: string[] = []; + for (const [relativePath, { content, mode }] of pendingEnvFiles) { + const target = path.join(localConfig.repo.localPath, ...relativePath.split('/')); + try { + await writeFile(target, content); + await chmod(target, mode); + } catch (e) { + lost.push(`${relativePath} (${(e as Error).message})`); + } + } + return lost; + }; + const reportLostEnvFiles = (lost: string[]): void => { + log.error( + `Could not put back ${lost.join(', ')} after resetting the team repo, so that env edit is no longer in ` + + 'the clone. Nothing more was pushed. Run `teamai env add` again for the variables it held, then push.', + ); + process.exitCode = 1; + }; + // Env files the refresh below could not put back; the push stops on any. + let lostEnvFiles: string[] = []; // Set when the pull below failed: everything read from the clone after this // point is the previous pull's, manifests included. let teamRepoStale = false; @@ -894,10 +941,32 @@ async function pushCore( if (pendingTeamConfig !== null && await hasGitModeChange(git, 'teamai.yaml')) { modeChangedPaths.add('teamai.yaml'); } + // `env add` edits env files in the clone and leaves the commit to push: + // capture what the env scan will push, to restore it after the refresh + // below as teamai.yaml is (#881). A mode change is not captured, so it + // stays dirty and stops the push. So does any index state: the snapshot + // holds only the working copy, and reset --hard would drop a staged, + // conflicted, deleted or renamed entry. + const status = await git.status(); + const indexedPaths = new Set(collectDirtyPaths({ + staged: status.staged, + created: status.created, + conflicted: status.conflicted, + deleted: status.deleted, + renamed: status.renamed, + })); + for (const item of await getHandler('env').scanLocalForPush(teamConfig, localConfig)) { + if (indexedPaths.has(item.relativePath)) continue; + const snapshot = await readFileSnapshot(item.sourcePath); + if (snapshot !== null && !await hasGitModeChange(git, item.relativePath)) { + pendingEnvFiles.set(item.relativePath, snapshot); + } + } const unsafeDirtyPaths = collectUnsafeDirtyPaths( - await git.status(), + status, pendingTeamConfig, modeChangedPaths, + new Set(pendingEnvFiles.keys()), ); if (unsafeDirtyPaths.length > 0) { pullSpin.fail( @@ -907,8 +976,14 @@ async function pushCore( process.exitCode = 1; return; } - await resetToCleanMaster(git, repoPath); - await pullRepo(repoPath); + try { + await resetToCleanMaster(git, repoPath); + await pullRepo(repoPath); + } finally { + // Unlike teamai.yaml, nothing later in the run holds these edits, so + // they go back even when the refresh fails after reset --hard. + lostEnvFiles = await restorePendingEnvFiles(); + } if (pendingTeamConfig !== null) { // Re-apply the TeamAI-owned config edit after refreshing the default branch. await writeFile(yamlPath, pendingTeamConfig); @@ -918,6 +993,10 @@ async function pushCore( teamRepoStale = true; pullSpin.warn(`Pull failed: ${(e as Error).message}`); } + if (lostEnvFiles.length > 0) { + reportLostEnvFiles(lostEnvFiles); + return; + } } // Validate the refreshed checkout, not the stale local clone. A pull can @@ -1626,17 +1705,35 @@ async function pushCore( includeTeamConfig: groupIndex === configGroupIndex, branch: options.branch, }); + // A failure can leave the clone on the group's local branch, where an + // unpushed commit holds the config and env edits: go back to the default + // branch first, where the next run finds them again. + const configPending = pendingTeamConfig !== null && groupIndex <= configGroupIndex; + if (outcome === 'failed' && (configPending || pendingEnvFiles.size > 0)) { + await checkoutMaster(localConfig.repo.localPath); + } // A preceding reuse group may take the metadata-only path in // pushRepoBranch(), which resets and cleans the clone. Re-apply the // captured config before the new explicit-branch group runs, or that - // cleanup would silently discard the user's edit (#800). - if (pendingTeamConfig !== null && groupIndex < configGroupIndex) { + // cleanup would silently discard the user's edit (#800). The same goes + // for the config group itself when it failed. + if (pendingTeamConfig !== null && configPending && (groupIndex < configGroupIndex || outcome === 'failed')) { await writeFile(path.join(localConfig.repo.localPath, 'teamai.yaml'), pendingTeamConfig); } - if (outcome === 'failed') { + // A group that pushed a branch carries its own env files, so those are no + // longer pending. Any group may have reset and cleaned the clone on the way + // out (the rollback, or pushRepoBranch's no-change path, which a reuse group + // retrying its PR also takes before it reports pushed), so the rest go back + // whatever the outcome; rewriting a file still in place changes nothing. + if (outcome === 'pushed' || outcome === 'pr-failed') { + for (const item of group.items) pendingEnvFiles.delete(item.relativePath); + } + const lost = await restorePendingEnvFiles(); + if (outcome === 'failed' || lost.length > 0) { // The branch/PR for earlier groups is already on the remote, so their // records must survive this failure or the next run would duplicate them. await saveStateForScope(pushState, localConfig); + if (lost.length > 0) reportLostEnvFiles(lost); process.exitCode = 1; return; } @@ -1679,6 +1776,9 @@ async function pushCore( options, anyPrFailed ? undefined : result, ); + // Its no-change path resets the clone too, and it commits teamai.yaml only. + const lost = await restorePendingEnvFiles(); + if (lost.length > 0) reportLostEnvFiles(lost); return; } diff --git a/src/resources/env.ts b/src/resources/env.ts index 2b386e133..d35718136 100644 --- a/src/resources/env.ts +++ b/src/resources/env.ts @@ -274,13 +274,13 @@ export class EnvHandler extends ResourceHandler { } async pushItem(item: ResourceItem, _teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { - // Non-self modes: env files already live in the repo dir; push.ts commits - // them via the env/ sweeper — nothing to copy. + // Non-self modes: env files already live in the repo dir; push.ts stages + // each selected one by its path — nothing to copy. // // Single-repo mode: the source is the ACTIVE tree's .teamai/env/ file, but // the commit happens in the knowledge worktree (localConfig.repo.localPath). // Copy the active copy into the worktree so the PR actually carries the change; - // otherwise the env/ sweeper would commit the stale baseline. (Guarded on the + // otherwise staging that path would commit the stale baseline. (Guarded on the // paths differing so non-self stays a no-op.) if (isSelfMode(localConfig)) { const dest = path.join(localConfig.repo.localPath, ...item.relativePath.split('/')); diff --git a/src/utils/fs.ts b/src/utils/fs.ts index ae094a7ca..290e0803a 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -52,9 +52,9 @@ export async function readFileIfExists(filePath: string): Promise } /** - * Write a file, creating parent dirs as needed. + * Write a file, creating parent dirs as needed. Bytes are written as they are. */ -export async function writeFile(filePath: string, content: string): Promise { +export async function writeFile(filePath: string, content: string | Uint8Array): Promise { const expanded = expandHome(filePath); await fse.ensureDir(path.dirname(expanded)); await fse.writeFile(expanded, content, 'utf-8');