From a5ed3a5cfc584ceb761b855a98a1126086dde69a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:02:43 +0800 Subject: [PATCH] refactor(dev-infra): narrow change scope report --- ...-27-explicit-change-scope-report.i18n.yaml | 4 +- ...2026-07-27-explicit-change-scope-report.md | 8 +- ...6-07-27-explicit-change-scope-report.zh.md | 8 +- .agents/skills/dsh-code-review/SKILL.md | 2 +- .agents/skills/dsh-doc-standards/SKILL.md | 2 +- .agents/skills/dsh-pre-push-checks/SKILL.md | 4 +- scripts/change-scope.spec.ts | 130 ++---------------- scripts/change-scope.ts | 90 ++---------- 8 files changed, 39 insertions(+), 209 deletions(-) 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 1c39083a6e..b7072336ea 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: 2cce567940a142ed1f4699f4dc67322ed69565e9 -2026-07-27-explicit-change-scope-report.zh.md: cc08df3ee4f3d681bc4ae8b2b0eab3588dfe1a73 +2026-07-27-explicit-change-scope-report.md: ed09ffc44252e1e571d50537b3471cad8d68d8f9 +2026-07-27-explicit-change-scope-report.zh.md: e13092395d6708b1ec030d13365853a5eaecba55 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 2cce567940..ed09ffc442 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; 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. +The root `change-scope` command requires `--base `, accepts `--head ` with `HEAD` as the default, and writes one versioned JSON report. It resolves both inputs to commits with ambiguity detection and requires one merge base before rendering. The report records the repository root without normalizing legal path whitespace, 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 and every path are decoded as strict UTF-8. An invalid value aborts the report 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. 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, 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. +Focused temporary-repository tests cover explicit and stacked refs, every dirty layer, legal path whitespace, strict path decoding, inert probes, invalid refs, the deterministic schema, and unchanged refs, index, config, and status after reporting. ## Alternatives considered @@ -30,9 +30,11 @@ Focused temporary-repository tests cover a fresh branch tracking `origin/master` **Generate required tests from changed paths.** Paths cannot establish behavior reached through configuration, dynamic loading, subprocesses, workers, built artifacts, or providers. Evidence selection remains judgment under the pre-push workflow. +**Report current branch and upstream and maintain a parallel human renderer.** Callers already verify branch and base state before invocation, no consumer uses those fields, and formatted prose duplicates the JSON schema without improving path completeness. + ## Consequences -The explicit input makes an incorrect base possible but visible: both input refs and all three resolved commit IDs appear in either output form. Callers pay the small cost of verifying and fetching the live base before running the command. +The explicit input makes an incorrect base possible but visible: both input refs and all three resolved commit IDs appear in the report. Callers pay the small cost of verifying and fetching the live base before running the command. The string schema deliberately cannot represent non-UTF-8 path bytes. A repository containing them must rename those paths before it can produce a report, preserving exact scope instead of returning a lossy one. 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 cc08df3ee4..e13092395d 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 与索引。每次 Git 探测都会禁用配置的文件系统监视器和可选加锁;diff 配置不能隐藏子模块,也不能调用外部 diff 或文本转换驱动;系统禁用重命名检测,因此重命名前后的路径都会保留在结果中。 该命令从不猜测或获取基准,不查询代码托管提供方,也不选择测试。调用该命令的每个工作流都会验证当前远端或堆叠状态、显式提供基准,并将这份事实报告作为语义评审或证据选择的输入。 -聚焦的临时仓库测试覆盖以下情形:新分支跟踪 `origin/master` 但没有同名远端分支;同一分支推送后的上游配置;以合法空白字符结尾的 worktree 路径;堆叠分支以非 master 分支为基准;所有未提交改动层;配置的文件系统监视器不会执行;互异的非 UTF-8 POSIX 路径、分支名或上游名会使报告失败且不产生部分输出;无效、有歧义及不指向 commit 的引用;人类可读输出与 JSON 输出保持确定性一致。测试还确认生成报告前后,引用、索引、配置与状态均不发生变化。 +聚焦的临时仓库测试覆盖显式引用与堆叠引用、所有未提交改动层、合法路径空白、严格路径解码、无副作用的探测、无效引用、确定性 schema,以及报告前后不变的引用、索引、配置与状态。 ## 考虑过的替代方案 @@ -30,9 +30,11 @@ Status: implemented **根据变更路径生成必需的测试。** 变更路径无法揭示经由配置、动态加载、子进程、worker、构建产物或提供方触达的行为。pre-push 工作流仍须通过判断来选择证据。 +**报告当前分支与上游,并维护并行的人类可读渲染器。** 调用方在调用前已经验证分支和基准状态,没有消费方使用这些字段,而格式化文字只会重复 JSON schema,并不能提高路径完整性。 + ## 结果 -显式输入仍可能指定错误的基准,但这种错误是可见的:两种输出格式都会显示输入引用与解析出的三个 commit ID。调用方需要付出少量成本,在运行该命令前验证实时基准并从远端获取它。 +显式输入仍可能指定错误的基准,但这种错误是可见的:报告中会显示输入引用与解析出的三个 commit ID。调用方需要付出少量成本,在运行该命令前验证实时基准并从远端获取它。 字符串 schema 有意不表示非 UTF-8 路径字节。含有这类路径的仓库必须先重命名这些路径才能生成报告,以此保持范围精确,而非返回有损结果。 diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index d9dd441374..f933beb86b 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -5,7 +5,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — # Reviewing a DeepSeek-Harness PR -**This skill is guidance, not a complete checklist.** Verify and fetch the PR's live base and exact head, then run `pnpm run change-scope --base --head ` before reading the diff and enough surrounding code to understand the design. The report identifies paths and dirty layers but does not replace semantic review. Re-establish the base and rerun it after a retarget or merge. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits. +**This skill is guidance, not a complete checklist.** Verify and fetch the PR's live base and exact head, then run `pnpm --silent run change-scope --base --head ` before reading the diff and enough surrounding code to understand the design. The report identifies paths and dirty layers but does not replace semantic review. Re-establish the base and rerun it after a retarget or merge. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits. ## Sources of truth diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md index 3f817fa464..202b4bcfd8 100644 --- a/.agents/skills/dsh-doc-standards/SKILL.md +++ b/.agents/skills/dsh-doc-standards/SKILL.md @@ -26,7 +26,7 @@ Run the placement test in the standard's taxonomy table, then check the constrai ## Auditing the corpus -The audit is a hunt for the standard's slop checklist, cheapest probes first. Verify and fetch the PR's live base, then run `pnpm run change-scope --base ` to identify committed and dirty paths before applying semantic judgment. After a retarget or base merge, rerun the report and repeat the audit for prose introduced by the new base rather than relying on the earlier result. +The audit is a hunt for the standard's slop checklist, cheapest probes first. Verify and fetch the PR's live base, then run `pnpm --silent run change-scope --base ` to identify committed and dirty paths before applying semantic judgment. After a retarget or base merge, rerun the report and repeat the audit for prose introduced by the new base rather than relying on the earlier result. 1. Measure: `pnpm run verify-doc-budgets --list`, then `git ls-files '*.md' ':(exclude)vendor/**' | xargs wc -w | sort -rn | head -30` to spot unbudgeted outliers. 2. Hunt narrated history: `rg -n "no longer|used to|previously|was moved|renamed" --glob '*.md' --glob '*.ts' --glob '!vendor/**'` and keep only contrasts against a live alternative. Keep the vendor exclusion last so include globs cannot override it. diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index b00b28f6c9..fe5de961a9 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -19,10 +19,10 @@ git rev-parse --show-toplevel 2. Verify the live PR base or stack parent, fetch that ref, and inspect the complete scope against it. ```sh -pnpm run change-scope --base +pnpm --silent run change-scope --base ``` -The command never guesses or fetches a base. Supply the ref verified from current remote or stack state; use `--head ` when inspecting a commit other than `HEAD`, and `--json` when another tool consumes the report. Its committed paths are relative to the resolved merge base, while staged, unstaged, and untracked paths describe the current worktree. After merging a changed base, rerun the report, reassess which behavior the combined scope can affect, and rerun only checks invalidated by the merge. +The command never guesses or fetches a base. Supply the ref verified from current remote or stack state; use `--head ` when inspecting a commit other than `HEAD`. Its versioned JSON records committed paths relative to the resolved merge base, while staged, unstaged, and untracked paths describe the current worktree. After merging a changed base, rerun the report, reassess which behavior the combined scope can affect, and rerun only checks invalidated by the merge. ## Select relevant evidence diff --git a/scripts/change-scope.spec.ts b/scripts/change-scope.spec.ts index 6f1493d401..a9ba98407b 100644 --- a/scripts/change-scope.spec.ts +++ b/scripts/change-scope.spec.ts @@ -4,11 +4,11 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { writeChangeScope } from './change-scope.ts' +import { renderChangeScope } from './change-scope.ts' interface Report { formatVersion: number - repository: { root: string; branch: string | null; upstream: string | null } + repositoryRoot: string input: { base: string; head: string } resolved: { baseSha: string; headSha: string; mergeBaseSha: string } paths: { committed: string[]; staged: string[]; unstaged: string[]; untracked: string[] } @@ -17,7 +17,6 @@ interface Report { interface Fixture { container: string root: string - origin: string } const fixtureRoots: string[] = [] @@ -66,7 +65,7 @@ function fixture(worktreeName = 'worktree'): Fixture { git(root, ['commit', '-m', 'initial']) git(root, ['remote', 'add', 'origin', origin]) git(root, ['push', '--set-upstream', 'origin', 'master']) - return { container, root, origin } + return { container, root } } function commit(root: string, path: string, content: string): string { @@ -77,42 +76,15 @@ function commit(root: string, path: string, content: string): string { } function invoke(root: string, args: string[]): string { - const output: string[] = [] - writeChangeScope(args, root, chunk => output.push(chunk)) - expect(output).toHaveLength(1) - return output[0] as string + return renderChangeScope(args, root) } function jsonReport(root: string, base: string, head?: string): Report { - const args = ['--base', base, '--json'] + const args = ['--base', base] if (head !== undefined) args.push('--head', head) return JSON.parse(invoke(root, args)) as Report } -function formatHumanFromJson(report: Report): string { - const value = (input: string | null): string => JSON.stringify(input) ?? 'null' - const paths = (label: string, entries: string[]): string[] => [ - `${label} (${entries.length}):`, - ...(entries.length === 0 ? [' (none)'] : entries.map(entry => ` - ${value(entry)}`)), - ] - return [ - `Format version: ${report.formatVersion}`, - `Repository root: ${value(report.repository.root)}`, - `Branch: ${value(report.repository.branch)}`, - `Upstream: ${value(report.repository.upstream)}`, - `Base ref: ${value(report.input.base)}`, - `Head ref: ${value(report.input.head)}`, - `Base commit: ${report.resolved.baseSha}`, - `Head commit: ${report.resolved.headSha}`, - `Merge base: ${report.resolved.mergeBaseSha}`, - ...paths('Committed paths', report.paths.committed), - ...paths('Staged paths', report.paths.staged), - ...paths('Unstaged paths', report.paths.unstaged), - ...paths('Untracked paths', report.paths.untracked), - '', - ].join('\n') -} - function repositoryState(root: string): Record { const status = git(root, ['status', '--porcelain=v2', '--branch', '-z']) return { @@ -132,7 +104,7 @@ describe('change-scope', () => { const headSha = commit(root, 'feature.txt', 'feature\n') const fresh = jsonReport(root, 'origin/master') - expect(fresh.repository).toEqual({ root: realpathSync(root), branch: 'feature', upstream: 'origin/master' }) + expect(fresh.repositoryRoot).toBe(realpathSync(root)) expect(fresh.resolved).toEqual({ baseSha: git(root, ['rev-parse', 'origin/master']), headSha, @@ -143,7 +115,6 @@ describe('change-scope', () => { git(root, ['push', '--set-upstream', 'origin', 'feature']) const pushed = jsonReport(root, 'origin/master') - expect(pushed.repository.upstream).toBe('origin/feature') expect(pushed.paths.committed).toEqual(['feature.txt']) }) @@ -151,24 +122,10 @@ describe('change-scope', () => { const { root } = fixture('worktree ') const report = jsonReport(root, 'HEAD') - expect(report.repository.root).toBe(realpathSync(root)) + expect(report.repositoryRoot).toBe(realpathSync(root)) expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] }) }) - it('preserves legal Unicode edge whitespace in branch and upstream names', () => { - const { root } = fixture() - const branch = '\u00a0topic\u3000' - const upstreamBranch = '\u3000upstream\u00a0' - git(root, ['switch', '-c', branch]) - git(root, ['push', 'origin', `HEAD:refs/heads/${upstreamBranch}`]) - git(root, ['branch', '--set-upstream-to', `origin/${upstreamBranch}`]) - - const report = jsonReport(root, 'origin/master') - - expect(report.repository.branch).toBe(branch) - expect(report.repository.upstream).toBe(`origin/${upstreamBranch}`) - }) - it('reports an exact head above a non-master stacked base while dirty paths remain worktree-local', () => { const { root } = fixture() git(root, ['switch', '-c', 'foundation']) @@ -222,55 +179,7 @@ describe('change-scope', () => { 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', () => { + it.skipIf(process.platform === 'win32')('rejects distinct non-UTF-8 Git paths', () => { const { root } = fixture() const blobSha = git(root, ['hash-object', '-w', '--stdin'], 'content') const entry = Buffer.from(`100644 ${blobSha}\t`, 'ascii') @@ -290,15 +199,12 @@ describe('change-scope', () => { secondPath, Buffer.from([0]), ])) - const output: string[] = [] - expect(() => { - writeChangeScope(['--base', 'HEAD', '--json'], root, chunk => output.push(chunk)) + renderChangeScope(['--base', 'HEAD'], root) }).toThrow('cannot inspect staged paths: Git path 1 is not valid UTF-8') - expect(output).toEqual([]) }) - it('rejects missing, ambiguous, and non-commit refs before writing output', () => { + it('rejects missing, ambiguous, and non-commit refs', () => { const { root } = fixture() git(root, ['branch', 'collision']) git(root, ['tag', 'collision']) @@ -312,32 +218,24 @@ describe('change-scope', () => { { args: ['--base', 'blob-ref'], message: /base ref .* does not resolve to a commit/u }, { args: ['--base', 'HEAD', '--head', 'missing'], message: /head ref .* does not resolve to a commit/u }, ]) { - const output: string[] = [] expect(() => { - writeChangeScope(args, root, (chunk) => { - output.push(chunk) - }) + renderChangeScope(args, root) }).toThrow(message) - expect(output).toEqual([]) } }) - it('renders deterministic human and JSON forms with the same facts', () => { + it('renders deterministic versioned JSON', () => { const { root } = fixture() git(root, ['switch', '-c', 'format']) commit(root, 'zeta.txt', 'zeta\n') commit(root, 'alpha.txt', 'alpha\n') - const json = invoke(root, ['--base', 'origin/master', '--json']) - const repeatedJson = invoke(root, ['--base', 'origin/master', '--json']) - const human = invoke(root, ['--base', 'origin/master']) - const repeatedHuman = invoke(root, ['--base', 'origin/master']) + const json = invoke(root, ['--base', 'origin/master']) + const repeatedJson = invoke(root, ['--base', 'origin/master']) const report = JSON.parse(json) as Report expect(json).toBe(repeatedJson) expect(report.formatVersion).toBe(1) expect(report.paths.committed).toEqual(['alpha.txt', 'zeta.txt']) - expect(human).toBe(repeatedHuman) - expect(human).toBe(formatHumanFromJson(report)) }) }) diff --git a/scripts/change-scope.ts b/scripts/change-scope.ts index e588f77284..d375a8712e 100644 --- a/scripts/change-scope.ts +++ b/scripts/change-scope.ts @@ -11,11 +11,7 @@ const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true }) interface ChangeScopeReport { formatVersion: typeof FORMAT_VERSION - repository: { - root: string - branch: string | null - upstream: string | null - } + repositoryRoot: string input: { base: string head: string @@ -50,7 +46,6 @@ interface GitBytesCommandResult { interface ChangeScopeOptions { base: string head: string - json: boolean } function executeGit(cwd: string, args: string[], context: string): GitCommandResult { @@ -111,12 +106,11 @@ function parseOptions(args: string[]): ChangeScopeOptions { options: { base: { type: 'string' }, head: { type: 'string', default: 'HEAD' }, - json: { type: 'boolean', default: false }, }, strict: true, }) if (values.base === undefined) throw new Error('missing required --base ') - return { base: values.base, head: values.head, json: values.json } + return { base: values.base, head: values.head } } function resolveCommit(root: string, label: 'base' | 'head', ref: string): string { @@ -158,33 +152,6 @@ function resolveMergeBase(root: string, baseSha: string, headSha: string): strin return mergeBases[0] as string } -function currentBranch(root: string): string | null { - 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) -} - -function configuredUpstream(root: string, branch: string | null): string | null { - if (branch === null) return null - const output = stripGitLineTerminator(requireGit( - root, - ['for-each-ref', '--count=1', '--format=%(upstream:short)', `refs/heads/${branch}`], - 'cannot inspect the configured upstream', - )) - return output === '' ? null : output -} - -function comparePaths(left: string, right: string): number { - if (left < right) return -1 - if (left > right) return 1 - return 0 -} - function parsePathSet(output: Buffer, context: string): string[] { const paths: string[] = [] let start = 0 @@ -201,7 +168,7 @@ function parsePathSet(output: Buffer, context: string): string[] { } start = end + 1 } - return [...new Set(paths)].sort(comparePaths) + return [...new Set(paths)].sort() } function diffPaths(root: string, args: string[], context: string): string[] { @@ -232,14 +199,9 @@ function collectReport(options: ChangeScopeOptions, cwd: string): ChangeScopeRep const baseSha = resolveCommit(root, 'base', options.base) const headSha = resolveCommit(root, 'head', options.head) const mergeBaseSha = resolveMergeBase(root, baseSha, headSha) - const branch = currentBranch(root) return { formatVersion: FORMAT_VERSION, - repository: { - root, - branch, - upstream: configuredUpstream(root, branch), - }, + repositoryRoot: root, input: { base: options.base, head: options.head, @@ -262,56 +224,22 @@ function collectReport(options: ChangeScopeOptions, cwd: string): ChangeScopeRep } } -function formatValue(value: string | null): string { - return JSON.stringify(value) -} - -function formatPaths(label: string, paths: string[]): string[] { - return [ - `${label} (${paths.length}):`, - ...(paths.length === 0 ? [' (none)'] : paths.map(path => ` - ${formatValue(path)}`)), - ] -} - -function formatHuman(report: ChangeScopeReport): string { - return [ - `Format version: ${report.formatVersion}`, - `Repository root: ${formatValue(report.repository.root)}`, - `Branch: ${formatValue(report.repository.branch)}`, - `Upstream: ${formatValue(report.repository.upstream)}`, - `Base ref: ${formatValue(report.input.base)}`, - `Head ref: ${formatValue(report.input.head)}`, - `Base commit: ${report.resolved.baseSha}`, - `Head commit: ${report.resolved.headSha}`, - `Merge base: ${report.resolved.mergeBaseSha}`, - ...formatPaths('Committed paths', report.paths.committed), - ...formatPaths('Staged paths', report.paths.staged), - ...formatPaths('Unstaged paths', report.paths.unstaged), - ...formatPaths('Untracked paths', report.paths.untracked), - ].join('\n') -} - /** - * Validate arguments, collect one complete report, then invoke the writer once. + * Validate arguments and render one complete versioned report. * @param args - Command-line arguments after the script path. * @param cwd - Directory whose containing Git worktree is inspected. - * @param write - Destination called once only after every Git query succeeds. - * @returns Nothing. + * @returns JSON report with a trailing newline. */ -export function writeChangeScope( - args: string[], - cwd: string, - write: (output: string) => void, -): void { +export function renderChangeScope(args: string[], cwd: string): string { const options = parseOptions(args) const report = collectReport(options, cwd) - write(`${options.json ? JSON.stringify(report, null, 2) : formatHuman(report)}\n`) + return `${JSON.stringify(report, null, 2)}\n` } const entryPath = process.argv[1] if (entryPath !== undefined && resolve(entryPath) === fileURLToPath(import.meta.url)) { try { - writeChangeScope(process.argv.slice(2), process.cwd(), output => process.stdout.write(output)) + process.stdout.write(renderChangeScope(process.argv.slice(2), process.cwd())) } catch (error) { const message = error instanceof Error ? error.message : String(error) process.stderr.write(`change-scope: ${message}\n`)