diff --git a/packages/local-runtime/src/files/git-process.ts b/packages/local-runtime/src/files/git-process.ts index c388ea575..6e2b63f66 100644 --- a/packages/local-runtime/src/files/git-process.ts +++ b/packages/local-runtime/src/files/git-process.ts @@ -12,6 +12,12 @@ export interface GitRunResult { code: number; stdout: string; stderr: string; + /** + * Set only when `git` never ran — a missing binary or an unusable working + * directory. Absent means Git produced a verdict, so `code` is a real exit + * status that callers may branch on instead of parsing a translated message. + */ + spawnError?: string; } export async function git(args: string[], workspace: string): Promise { @@ -29,6 +35,7 @@ export async function git(args: string[], workspace: string): Promise { + const probe = await git(['rev-parse', '--git-dir'], workspace); + if (probe.spawnError !== undefined) return 'workspace_unavailable'; + return probe.code === 0 && probe.stdout.trim() !== '' + ? 'workspace_unavailable' + : 'not_git_repository'; +} + /** null means confirmed missing; undefined means metadata could not be read. */ async function worktreeLastModifiedMs(worktreePath: string): Promise { try { diff --git a/packages/local-runtime/test/unit/worktree-discovery-code.test.ts b/packages/local-runtime/test/unit/worktree-discovery-code.test.ts new file mode 100644 index 000000000..3d365611b --- /dev/null +++ b/packages/local-runtime/test/unit/worktree-discovery-code.test.ts @@ -0,0 +1,124 @@ +// Discovery-code classification for `listWorkspaceGitWorktrees`, driven by a +// stubbed `git` so the assertions hold on hosts whose Git prints English only. +// +// Why the stub instead of a real repository: the regression this file pins is +// that the classification read Git's human-readable diagnostic. That text is +// translated by Git, so a real-subprocess test only reproduces it on a host +// carrying the matching catalogue. Here the stub returns exactly what a +// localized Git returns, and the code under test must classify it from the +// exit status alone. + +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GitRunResult } from '../../src/files/git-process.js'; + +const gitStub = vi.fn<(args: string[], workspace: string) => Promise>(); + +vi.mock('../../src/files/git-process.js', () => ({ + git: (args: string[], workspace: string) => gitStub(args, workspace), +})); + +const { listWorkspaceGitWorktrees } = await import('../../src/files/worktrees.js'); + +const NOT_A_REPO_ZH = 'fatal: 不是 git 仓库(或者任何父目录):.git'; +const NOT_A_REPO_EN = 'fatal: not a git repository (or any of the parent directories): .git'; +const NO_WORK_TREE_ZH = 'fatal: 该操作必须在一个工作区中运行'; + +let workspace: string; + +beforeEach(async () => { + gitStub.mockReset(); + workspace = await mkdtemp(join(tmpdir(), 'mcode-worktree-code-')); + await mkdir(join(workspace, 'plain-folder'), { recursive: true }); +}); + +afterEach(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +/** Answer the two `rev-parse` probes the discovery performs. */ +function stubRevParse(options: { + toplevel: GitRunResult; + gitDir: GitRunResult; +}): void { + gitStub.mockImplementation(async (args) => + args.includes('--git-dir') ? options.gitDir : options.toplevel, + ); +} + +function failed(code: number, stderr: string): GitRunResult { + return { code, stdout: '', stderr }; +} + +describe('listWorkspaceGitWorktrees discovery code', () => { + it.each([ + ['zh_CN', NOT_A_REPO_ZH], + ['en_US', NOT_A_REPO_EN], + ])('classifies a plain folder as not_git_repository under %s diagnostics', async (_tag, stderr) => { + stubRevParse({ + toplevel: failed(128, stderr), + gitDir: failed(128, stderr), + }); + + const result = await listWorkspaceGitWorktrees(join(workspace, 'plain-folder')); + + expect(result.success).toBe(false); + expect(result.code).toBe('not_git_repository'); + expect(result.error).toBe(stderr); + }); + + it('keeps a repository without a work tree out of the not-a-repository bucket', async () => { + // A bare repository answers `--git-dir` successfully while + // `--show-toplevel` fails; that is a repository this listing cannot walk, + // not a folder that was never one. + stubRevParse({ + toplevel: failed(128, NO_WORK_TREE_ZH), + gitDir: { code: 0, stdout: '.\n', stderr: '' }, + }); + + const result = await listWorkspaceGitWorktrees(workspace); + + expect(result.success).toBe(false); + expect(result.code).toBe('workspace_unavailable'); + }); + + it('falls back to the generic bucket when git could not run at all', async () => { + // No binary, unusable cwd: neither probe produced a Git verdict, so the + // discovery must not claim the folder was never a repository. + gitStub.mockImplementation(async () => ({ + code: 1, + stdout: '', + stderr: 'spawn git ENOENT', + spawnError: 'ENOENT', + })); + + const result = await listWorkspaceGitWorktrees(workspace); + + expect(result.success).toBe(false); + expect(result.code).toBe('workspace_unavailable'); + }); + + it('still lists the worktrees of a healthy repository', async () => { + gitStub.mockImplementation(async (args) => { + if (args.includes('--show-toplevel')) return { code: 0, stdout: `${workspace}\n`, stderr: '' }; + if (args.includes('worktree')) { + return { + code: 0, + stdout: `worktree ${workspace}\nHEAD 1111111111111111111111111111111111111111\nbranch refs/heads/main\n\n`, + stderr: '', + }; + } + return failed(1, 'unexpected call'); + }); + + const result = await listWorkspaceGitWorktrees(workspace); + + expect(result.success).toBe(true); + expect(result.worktrees).toHaveLength(1); + expect(result.worktrees[0]).toMatchObject({ branch: 'main', isMain: true }); + }); +}); diff --git a/packages/local-runtime/test/unit/worktree-locale-parity.test.ts b/packages/local-runtime/test/unit/worktree-locale-parity.test.ts new file mode 100644 index 000000000..e5465136d --- /dev/null +++ b/packages/local-runtime/test/unit/worktree-locale-parity.test.ts @@ -0,0 +1,105 @@ +// Locale parity for the worktree discovery codes, against a real `git`. +// +// The discovery used to recognise "not a repository" by matching Git's +// English diagnostic, so on a host whose Git speaks the user's language every +// plain folder degraded to the generic `workspace_unavailable` bucket. These +// cases run the real subprocess under a fixed locale and assert the discovery +// code, never the wording — a Git build without the requested catalogue simply +// stays in English and the expectation still holds. + +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { listWorkspaceGitWorktrees } from '../../src/files/worktrees.js'; + +const execFile = promisify(execFileCallback); + +const LOCALES = ['zh_CN.utf8', 'en_US.UTF-8']; + +let fixture: string; +let plainFolder: string; +let repository: string; + +beforeEach(async () => { + fixture = await mkdtemp(join(tmpdir(), 'mcode-worktree-locale-')); + plainFolder = join(fixture, 'plain-folder'); + repository = join(fixture, 'repository'); + await mkdir(plainFolder, { recursive: true }); + await mkdir(repository, { recursive: true }); + await git(repository, ['-c', 'init.defaultBranch=main', 'init', '-q']); + await git(repository, [ + '-c', + 'user.name=P21', + '-c', + 'user.email=p21@example.test', + 'commit', + '-q', + '--allow-empty', + '-m', + 'initial', + ]); +}); + +afterEach(async () => { + await rm(fixture, { recursive: true, force: true }); +}); + +async function git(cwd: string, args: string[]): Promise { + await execFile('git', args, { cwd, encoding: 'utf-8' }); +} + +/** + * `git` translates its diagnostics from `LC_ALL`/`LANG`/`LC_MESSAGES`; the + * discovery inherits this process's environment, so pinning the variables here + * is what fixes the subprocess locale. Restoration is unconditional: another + * case in this file must not inherit the previous locale. + */ +async function withLocale(locale: string, run: () => Promise): Promise { + const saved = ['LANG', 'LC_ALL', 'LC_MESSAGES'].map((key) => [key, process.env[key]] as const); + process.env.LANG = locale; + process.env.LC_ALL = locale; + delete process.env.LC_MESSAGES; + try { + return await run(); + } finally { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +describe('listWorkspaceGitWorktrees locale parity', () => { + it.each(LOCALES)('reports a plain folder as not_git_repository under %s', async (locale) => { + const result = await withLocale(locale, () => listWorkspaceGitWorktrees(plainFolder)); + + expect(result.success).toBe(false); + expect(result.code).toBe('not_git_repository'); + expect(result.error).toBeTruthy(); + }); + + it.each(LOCALES)('lists the worktrees of a repository under %s', async (locale) => { + const result = await withLocale(locale, () => listWorkspaceGitWorktrees(repository)); + + expect(result.success).toBe(true); + expect(result.code).toBeUndefined(); + expect(result.worktrees).toHaveLength(1); + expect(result.worktrees[0]).toMatchObject({ branch: 'main', isMain: true, isActive: true }); + }); + + it('produces the same codes regardless of the locale in force', async () => { + const perLocale = []; + for (const locale of LOCALES) { + perLocale.push( + (await withLocale(locale, () => listWorkspaceGitWorktrees(plainFolder))).code, + (await withLocale(locale, () => listWorkspaceGitWorktrees(repository))).code, + ); + } + expect(perLocale).toEqual(['not_git_repository', undefined, 'not_git_repository', undefined]); + }); +}); diff --git a/packages/webui/server/engine/model-source.js b/packages/webui/server/engine/model-source.js index a034142d2..3cd0beb17 100644 --- a/packages/webui/server/engine/model-source.js +++ b/packages/webui/server/engine/model-source.js @@ -310,9 +310,14 @@ export function publicApiKeyStatus(status) { const record = status && typeof status === "object" ? status : {}; const cached = record.cachedStatus && typeof record.cachedStatus === "object" ? record.cachedStatus : {}; const lastTested = typeof cached.lastTestedAt === "number" ? cached.lastTestedAt : null; + // Destructured, not read as `record.hasApiKey` in the projection: the bundler + // renames `record` to a generated identifier, and a `hasKey: .` + // pair reads to the credential scanner as `hasKey = <16+ char secret>` inside the + // bundled distribution, failing the release audit on a boolean comparison. + const { hasApiKey } = record; return { available: true, - hasKey: record.hasApiKey === true, + hasKey: hasApiKey === true, masked: typeof record.maskedApiKey === "string" ? record.maskedApiKey : null, testState: typeof cached.state === "string" ? cached.state : null, lastTestedAtMs: lastTested, diff --git a/release/public-source.json b/release/public-source.json index a59d471cc..d626fdf7a 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -2730,6 +2730,8 @@ "packages/local-runtime/test/unit/thread-goal/host-integration-testkit.ts", "packages/local-runtime/test/unit/thread-goal/host-integration-verifier-charge-rearm.test.ts", "packages/local-runtime/test/unit/token-counter-adapter-routing.test.ts", + "packages/local-runtime/test/unit/worktree-discovery-code.test.ts", + "packages/local-runtime/test/unit/worktree-locale-parity.test.ts", "packages/mcode-tools-host/README.md", "packages/mcode-tools-host/package.json", "packages/mcode-tools-host/src/contracts.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 6ef1ba3a6..c33ddfe9a 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -110,6 +110,8 @@ "packages/local-runtime/test/unit/thread-goal/host-integration-settlement.test.ts", "packages/local-runtime/test/unit/thread-goal/host-integration-verifier-charge-rearm.test.ts", "packages/local-runtime/test/unit/token-counter-adapter-routing.test.ts", + "packages/local-runtime/test/unit/worktree-discovery-code.test.ts", + "packages/local-runtime/test/unit/worktree-locale-parity.test.ts", "packages/mcode-tools-host/test/unit/lease-broker.test.ts", "packages/mcode-tools-host/test/unit/resource-manifest.test.ts", "packages/mcode-tools-host/test/unit/resource.test.ts",