diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml index 05c5c3ab35..1c39083a6e 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md -2026-07-27-explicit-change-scope-report.md: aa6398368d5f161ed852aa92c1d9d35dd2564a5e -2026-07-27-explicit-change-scope-report.zh.md: 042258122d43e5da81cd470e1cf5af09dfeb2fba +2026-07-27-explicit-change-scope-report.md: 2cce567940a142ed1f4699f4dc67322ed69565e9 +2026-07-27-explicit-change-scope-report.zh.md: cc08df3ee4f3d681bc4ae8b2b0eab3588dfe1a73 diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md index aa6398368d..2cce567940 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md @@ -12,13 +12,13 @@ An incorrect range undermines evidence selection because it can omit affected pa ## Decision -The root `change-scope` command requires `--base `, accepts `--head ` with `HEAD` as the default, and offers a versioned `--json` form. It resolves both inputs to commits with ambiguity detection and requires one merge base before writing output. The report records the repository root without normalizing legal path whitespace, current branch, configured upstream, input refs, resolved base, head, and merge-base commit IDs, plus sorted committed, staged, unstaged, and untracked path sets. Path records are split at raw NUL bytes and decoded as strict UTF-8; an invalid record aborts the report before output instead of substituting characters or collapsing distinct paths. +The root `change-scope` command requires `--base `, accepts `--head ` with `HEAD` as the default, and offers a versioned `--json` form. It resolves both inputs to commits with ambiguity detection and requires one merge base before writing output. The report records the repository root without normalizing legal path whitespace, current branch, configured upstream, input refs, resolved base, head, and merge-base commit IDs, plus sorted committed, staged, unstaged, and untracked path sets. Path records are split at raw NUL bytes; the repository root, branch, upstream, and every path are decoded as strict UTF-8. An invalid value aborts the report before output instead of substituting characters or collapsing distinct values. -Committed paths compare the resolved merge base with the resolved head. Dirty path sets always describe the current worktree and index, even when `--head` names another commit. Diff configuration cannot hide submodules or invoke external diff or text-conversion drivers, and rename detection is disabled so both sides of a rename remain visible. +Committed paths compare the resolved merge base with the resolved head. Dirty path sets always describe the current worktree and index, even when `--head` names another commit. Every Git probe disables configured filesystem monitors and optional lock-taking; diff configuration cannot hide submodules or invoke external diff or text-conversion drivers, and rename detection is disabled so both sides of a rename remain visible. The command never guesses or fetches a base, queries a hosting provider, or selects tests. Each calling workflow verifies current remote or stack state, supplies the base explicitly, and uses the factual report as input to semantic review or evidence selection. -Focused temporary-repository tests cover a fresh branch tracking `origin/master` without a same-name remote, its post-push upstream, a worktree path ending in legal whitespace, a stacked non-master base, every dirty layer, distinct non-UTF-8 POSIX paths failing without partial output, invalid, ambiguous, and non-commit refs, deterministic human/JSON parity, and unchanged refs, index, config, and status after reporting. +Focused temporary-repository tests cover a fresh branch tracking `origin/master` without a same-name remote, its post-push upstream, a worktree path ending in legal whitespace, a stacked non-master base, every dirty layer, a configured filesystem monitor remaining unexecuted, distinct non-UTF-8 POSIX paths and branch or upstream names failing without partial output, invalid, ambiguous, and non-commit refs, deterministic human/JSON parity, and unchanged refs, index, config, and status after reporting. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md index 042258122d..cc08df3ee4 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -根目录的 `change-scope` 命令要求提供 `--base `,接受可选的 `--head `(默认为 `HEAD`),并提供带版本号的 `--json` 输出格式。该命令会检测歧义,将两个输入解析为 commit,并要求二者恰好有一个合并基点,之后才会输出结果。报告记录仓库根目录(不对路径中的合法空白字符作规范化处理)、当前分支、配置的上游、输入引用、解析后的基准、头部与合并基点 commit ID,以及排序后的已提交、已暂存、未暂存和未跟踪路径集合。路径记录先按原始 NUL 字节切分,再逐条以严格 UTF-8 解码;遇到无效记录时,命令会在写出任何结果前失败,不会用替换字符代替无效字节或把不同路径合并为一条。 +根目录的 `change-scope` 命令要求提供 `--base `,接受可选的 `--head `(默认为 `HEAD`),并提供带版本号的 `--json` 输出格式。该命令会检测歧义,将两个输入解析为 commit,并要求二者恰好有一个合并基点,之后才会输出结果。报告记录仓库根目录(不对路径中的合法空白字符作规范化处理)、当前分支、配置的上游、输入引用、解析后的基准、头部与合并基点 commit ID,以及排序后的已提交、已暂存、未暂存和未跟踪路径集合。路径记录先按原始 NUL 字节切分;仓库根目录、分支、上游和每条路径都以严格 UTF-8 解码。遇到无效值时,命令会在写出任何结果前失败,不会用替换字符代替无效字节或把不同值合并为一条。 -已提交路径由解析后的合并基点与头部之间的比较得出。即使 `--head` 指定其他 commit,各类未提交路径集合仍始终描述当前 worktree 与索引。diff 配置不能隐藏子模块,也不能调用外部 diff 或文本转换驱动;系统禁用重命名检测,因此重命名前后的路径都会保留在结果中。 +已提交路径由解析后的合并基点与头部之间的比较得出。即使 `--head` 指定其他 commit,各类未提交路径集合仍始终描述当前 worktree 与索引。每次 Git 探测都会禁用配置的文件系统监视器和可选加锁;diff 配置不能隐藏子模块,也不能调用外部 diff 或文本转换驱动;系统禁用重命名检测,因此重命名前后的路径都会保留在结果中。 该命令从不猜测或获取基准,不查询代码托管提供方,也不选择测试。调用该命令的每个工作流都会验证当前远端或堆叠状态、显式提供基准,并将这份事实报告作为语义评审或证据选择的输入。 -聚焦的临时仓库测试覆盖以下情形:新分支跟踪 `origin/master` 但没有同名远端分支;同一分支推送后的上游配置;以合法空白字符结尾的 worktree 路径;堆叠分支以非 master 分支为基准;所有未提交改动层;两个互异的非 UTF-8 POSIX 路径会使报告失败且不产生部分输出;无效、有歧义及不指向 commit 的引用;人类可读输出与 JSON 输出保持确定性一致。测试还确认生成报告前后,引用、索引、配置与状态均不发生变化。 +聚焦的临时仓库测试覆盖以下情形:新分支跟踪 `origin/master` 但没有同名远端分支;同一分支推送后的上游配置;以合法空白字符结尾的 worktree 路径;堆叠分支以非 master 分支为基准;所有未提交改动层;配置的文件系统监视器不会执行;互异的非 UTF-8 POSIX 路径、分支名或上游名会使报告失败且不产生部分输出;无效、有歧义及不指向 commit 的引用;人类可读输出与 JSON 输出保持确定性一致。测试还确认生成报告前后,引用、索引、配置与状态均不发生变化。 ## 考虑过的替代方案 diff --git a/scripts/change-scope.spec.ts b/scripts/change-scope.spec.ts index db3a80dae7..6f1493d401 100644 --- a/scripts/change-scope.spec.ts +++ b/scripts/change-scope.spec.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -43,9 +43,9 @@ function gitBytes(cwd: string, args: string[], input?: Buffer): Buffer { }) } -function write(path: string, content: string): void { +function write(path: string, content: string, mode?: number): void { mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, content) + writeFileSync(path, content, mode === undefined ? undefined : { mode }) } function fixture(worktreeName = 'worktree'): Fixture { @@ -209,6 +209,67 @@ describe('change-scope', () => { expect(repositoryState(root)).toEqual(before) }) + it.skipIf(process.platform === 'win32')('does not execute a configured filesystem monitor', () => { + const { container, root } = fixture() + const monitor = join(container, 'fsmonitor.sh') + const sideEffect = `${monitor}.ran` + write(monitor, '#!/bin/sh\ntouch "$0.ran"\n', 0o755) + git(root, ['config', 'core.fsmonitor', monitor]) + + const report = jsonReport(root, 'HEAD') + + expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] }) + expect(existsSync(sideEffect)).toBe(false) + }) + + it.skipIf(process.platform === 'win32')('rejects non-UTF-8 branch and upstream names without partial output', () => { + const invalidBranch = fixture() + const branchHead = git(invalidBranch.root, ['rev-parse', 'HEAD']) + const invalidBranchName = Buffer.from([0x80]) + writeFileSync(join(invalidBranch.root, '.git/packed-refs'), Buffer.concat([ + Buffer.from(`${branchHead} refs/heads/`), + invalidBranchName, + Buffer.from('\n'), + ])) + writeFileSync( + join(invalidBranch.root, '.git/HEAD'), + Buffer.concat([Buffer.from('ref: refs/heads/'), invalidBranchName, Buffer.from('\n')]), + ) + const branchOutput: string[] = [] + + expect(() => { + writeChangeScope(['--base', branchHead, '--json'], invalidBranch.root, chunk => branchOutput.push(chunk)) + }).toThrow('cannot inspect the current branch: Git stdout is not valid UTF-8') + expect(branchOutput).toEqual([]) + + const invalidUpstream = fixture() + const upstreamHead = git(invalidUpstream.root, ['rev-parse', 'HEAD']) + const invalidUpstreamName = Buffer.from([0x81]) + writeFileSync(join(invalidUpstream.root, '.git/packed-refs'), Buffer.concat([ + Buffer.from(`${upstreamHead} refs/remotes/origin/`), + invalidUpstreamName, + Buffer.from('\n'), + ])) + const configPath = join(invalidUpstream.root, '.git/config') + const config = readFileSync(configPath) + const merge = Buffer.from('\tmerge = refs/heads/master\n') + const mergeIndex = config.indexOf(merge) + expect(mergeIndex).toBeGreaterThanOrEqual(0) + writeFileSync(configPath, Buffer.concat([ + config.subarray(0, mergeIndex), + Buffer.from('\tmerge = refs/heads/'), + invalidUpstreamName, + Buffer.from('\n'), + config.subarray(mergeIndex + merge.length), + ])) + const upstreamOutput: string[] = [] + + expect(() => { + writeChangeScope(['--base', upstreamHead, '--json'], invalidUpstream.root, chunk => upstreamOutput.push(chunk)) + }).toThrow('cannot inspect the configured upstream: Git stdout is not valid UTF-8') + expect(upstreamOutput).toEqual([]) + }) + it.skipIf(process.platform === 'win32')('rejects distinct non-UTF-8 Git paths without partial output', () => { const { root } = fixture() const blobSha = git(root, ['hash-object', '-w', '--stdin'], 'content') diff --git a/scripts/change-scope.ts b/scripts/change-scope.ts index fdd1c8566d..e588f77284 100644 --- a/scripts/change-scope.ts +++ b/scripts/change-scope.ts @@ -53,10 +53,19 @@ interface ChangeScopeOptions { json: boolean } -function executeGit(cwd: string, args: string[]): GitCommandResult { - const result = spawnSync('git', ['-C', cwd, ...args], { - encoding: 'utf8', - env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, +function executeGit(cwd: string, args: string[], context: string): GitCommandResult { + const result = executeGitBytes(cwd, args) + return { + status: result.status, + stdout: decodeGitText(result.stdout, context, 'stdout'), + stderr: decodeGitText(result.stderr, context, 'stderr'), + error: result.error, + } +} + +function executeGitBytes(cwd: string, args: string[]): GitBytesCommandResult { + const result = spawnSync('git', ['-C', cwd, '-c', 'core.fsmonitor=false', ...args], { + env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LANG: 'C', LC_ALL: 'C' }, maxBuffer: MAX_GIT_OUTPUT, }) return { @@ -67,16 +76,11 @@ function executeGit(cwd: string, args: string[]): GitCommandResult { } } -function executeGitBytes(cwd: string, args: string[]): GitBytesCommandResult { - const result = spawnSync('git', ['-C', cwd, ...args], { - env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, - maxBuffer: MAX_GIT_OUTPUT, - }) - return { - status: result.status, - stdout: result.stdout, - stderr: result.stderr, - error: result.error, +function decodeGitText(output: Buffer, context: string, stream: 'stdout' | 'stderr'): string { + try { + return UTF8_DECODER.decode(output) + } catch { + throw new Error(`${context}: Git ${stream} is not valid UTF-8`) } } @@ -85,7 +89,7 @@ function failureDetail(result: GitCommandResult): string { } function requireGit(cwd: string, args: string[], context: string): string { - const result = executeGit(cwd, args) + const result = executeGit(cwd, args, context) if (result.status !== 0) throw new Error(`${context}: ${failureDetail(result)}`) return result.stdout } @@ -116,6 +120,7 @@ function parseOptions(args: string[]): ChangeScopeOptions { } function resolveCommit(root: string, label: 'base' | 'head', ref: string): string { + const context = `cannot resolve ${label} ref ${JSON.stringify(ref)}` const result = executeGit(root, [ '-c', 'core.warnAmbiguousRefs=true', @@ -123,7 +128,7 @@ function resolveCommit(root: string, label: 'base' | 'head', ref: string): strin '--verify', '--end-of-options', `${ref}^{commit}`, - ]) + ], context) if (/\bambiguous\b/iu.test(result.stderr)) { throw new Error(`${label} ref ${JSON.stringify(ref)} is ambiguous; use a fully qualified ref or commit ID`) } @@ -138,7 +143,11 @@ function resolveCommit(root: string, label: 'base' | 'head', ref: string): strin } function resolveMergeBase(root: string, baseSha: string, headSha: string): string { - const result = executeGit(root, ['merge-base', '--all', baseSha, headSha]) + const result = executeGit( + root, + ['merge-base', '--all', baseSha, headSha], + 'cannot resolve the merge base', + ) if (result.status !== 0) { throw new Error(`base and head do not have a merge base: ${failureDetail(result)}`) } @@ -150,7 +159,11 @@ function resolveMergeBase(root: string, baseSha: string, headSha: string): strin } function currentBranch(root: string): string | null { - const result = executeGit(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']) + const result = executeGit( + root, + ['symbolic-ref', '--quiet', '--short', 'HEAD'], + 'cannot inspect the current branch', + ) if (result.status === 1) return null if (result.status !== 0) throw new Error(`cannot inspect the current branch: ${failureDetail(result)}`) return stripGitLineTerminator(result.stdout)