Merge pull request #762 from deepseek-harness/worktree/explicit-change-scope-report-20260727

feat(dev-infra): report explicit repository change scope
This commit is contained in:
Tianyi Cui
2026-07-28 00:36:31 +08:00
committed by GitHub
9 changed files with 753 additions and 6 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
@@ -0,0 +1,39 @@
# Agent Note: Report an explicit repository change scope
Status: implemented
English | [中文](2026-07-27-explicit-change-scope-report.zh.md)
## Problem
The [pre-push workflow](../../../skills/dsh-pre-push-checks/SKILL.md) needs the diff against the actual base, but constructing `origin/<current-branch>` fails for a new worktree branch that tracks `origin/master` before its first push and misstates a stacked branch whose PR targets another feature branch. The [code-review](../../../skills/dsh-code-review/SKILL.md) and [documentation-audit](../../../skills/dsh-doc-standards/SKILL.md) workflows need the same current-base judgment.
An incorrect range undermines evidence selection because it can omit affected paths. A three-dot committed diff also says nothing about Git's separate staged, unstaged, and untracked layers.
## Decision
The root `change-scope` command requires `--base <ref>`, accepts `--head <ref>` 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. 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.
## Alternatives considered
**Keep an ad hoc diff command plus a prose fallback.** This avoids a repository script but leaves normal new-worktree and stacked-base topologies inconsistent across workflows, and it omits dirty layers.
**Infer the base from the configured upstream.** An upstream may be `origin/master` before the first push, the same feature branch after a push, or a head branch whose PR targets another feature branch. No inference is correct for every topology.
**Query GitHub for the base inside the command.** This couples a local read-only report to one forge and to network credentials, yet still cannot resolve a branch with no PR.
**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.
## 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 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.
The repository owns one Git-topology helper and focused tests. In return, pre-push selection, code review, and documentation audit share a deterministic, read-only account of committed and local changes without importing forge or policy concerns.
@@ -0,0 +1,39 @@
# Agent Note: 显式报告仓库变更范围
Status: implemented
[English](2026-07-27-explicit-change-scope-report.md) | 中文
## 问题
[pre-push 工作流](../../../skills/dsh-pre-push-checks/SKILL.md)需要取得相对于实际基准的 diff,但按 `origin/<current-branch>` 构造引用存在两类问题:对于第一次推送前跟踪 `origin/master`、尚无同名远端分支的新 worktree 分支,该引用无法解析;对于 PR(Pull Request)以另一功能分支为基准的堆叠分支,该引用会错误描述基准。[代码评审](../../../skills/dsh-code-review/SKILL.md)与[文档审计](../../../skills/dsh-doc-standards/SKILL.md)工作流同样需要判断当前基准。
错误的范围可能遗漏受影响的路径,从而削弱证据选择。三点范围产生的已提交 diff 也完全无法说明 Git 中彼此独立的已暂存、未暂存与未跟踪层。
## 决策
根目录的 `change-scope` 命令要求提供 `--base <ref>`,接受可选的 `--head <ref>`(默认为 `HEAD`),并提供带版本号的 `--json` 输出格式。该命令会检测歧义,将两个输入解析为 commit,并要求二者恰好有一个合并基点,之后才会输出结果。报告记录仓库根目录(不对路径中的合法空白字符作规范化处理)、当前分支、配置的上游、输入引用、解析后的基准、头部与合并基点 commit ID,以及排序后的已提交、已暂存、未暂存和未跟踪路径集合。路径记录先按原始 NUL 字节切分;仓库根目录、分支、上游和每条路径都以严格 UTF-8 解码。遇到无效值时,命令会在写出任何结果前失败,不会用替换字符代替无效字节或把不同值合并为一条。
已提交路径由解析后的合并基点与头部之间的比较得出。即使 `--head` 指定其他 commit,各类未提交路径集合仍始终描述当前 worktree 与索引。每次 Git 探测都会禁用配置的文件系统监视器和可选加锁;diff 配置不能隐藏子模块,也不能调用外部 diff 或文本转换驱动;系统禁用重命名检测,因此重命名前后的路径都会保留在结果中。
该命令从不猜测或获取基准,不查询代码托管提供方,也不选择测试。调用该命令的每个工作流都会验证当前远端或堆叠状态、显式提供基准,并将这份事实报告作为语义评审或证据选择的输入。
聚焦的临时仓库测试覆盖以下情形:新分支跟踪 `origin/master` 但没有同名远端分支;同一分支推送后的上游配置;以合法空白字符结尾的 worktree 路径;堆叠分支以非 master 分支为基准;所有未提交改动层;配置的文件系统监视器不会执行;互异的非 UTF-8 POSIX 路径、分支名或上游名会使报告失败且不产生部分输出;无效、有歧义及不指向 commit 的引用;人类可读输出与 JSON 输出保持确定性一致。测试还确认生成报告前后,引用、索引、配置与状态均不发生变化。
## 考虑过的替代方案
**继续使用临时拼装的 diff 命令,辅以文字化回退说明。** 这种方式无需添加仓库脚本,但不同工作流对常见的新 worktree 与堆叠基准拓扑仍会作出不一致处理,而且无法涵盖未提交改动层。
**根据配置的上游推断基准。** 第一次推送前,上游可能是 `origin/master`;推送后,它可能是同一功能分支;也可能是一个头部分支,而其 PR 以另一功能分支为基准。没有一种推断能够适用于所有拓扑。
**在命令内查询 GitHub 以确定基准。** 这会把本地只读报告绑定到单一代码托管平台与网络凭证,却仍然无法解析尚无 PR 的分支。
**根据变更路径生成必需的测试。** 变更路径无法揭示经由配置、动态加载、子进程、worker、构建产物或提供方触达的行为。pre-push 工作流仍须通过判断来选择证据。
## 结果
显式输入仍可能指定错误的基准,但这种错误是可见的:两种输出格式都会显示输入引用与解析出的三个 commit ID。调用方需要付出少量成本,在运行该命令前验证实时基准并从远端获取它。
字符串 schema 有意不表示非 UTF-8 路径字节。含有这类路径的仓库必须先重命名这些路径才能生成报告,以此保持范围精确,而非返回有损结果。
仓库需要维护一个 Git 拓扑辅助工具及相应的聚焦测试。由此,pre-push 证据选择、代码评审与文档审计可以共享一份确定且只读的已提交及本地变更说明,而不必混入代码托管平台或策略职责。
+1 -1
View File
@@ -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.** Read the diff against the PR's current base and enough surrounding code to understand the design, then verify suspected defects before reporting them. Re-establish that base 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 run change-scope --base <verified-base-ref> --head <verified-head-ref>` 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
+1 -1
View File
@@ -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. Establish the PR's current base first; after a retarget or base merge, 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 run change-scope --base <verified-base-ref>` 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.
+3 -4
View File
@@ -16,14 +16,13 @@ git status --short --branch
git rev-parse --show-toplevel
```
2. Inspect the diff against its actual base.
2. Verify the live PR base or stack parent, fetch that ref, and inspect the complete scope against it.
```sh
git diff --stat
git diff --name-only origin/$(git branch --show-current)...HEAD
pnpm run change-scope --base <verified-base-ref>
```
If the branch has no upstream or that range is not meaningful for the stack, compare with the PR base branch. After merging a changed base, reassess which behavior the combined diff 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 <ref>` 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.
## Select relevant evidence
+1
View File
@@ -17,6 +17,7 @@
"build": "tsc -b && tsdown",
"build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build",
"clean": "tsx scripts/clean.ts",
"change-scope": "tsx scripts/change-scope.ts",
"typecheck": "tsc -b",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
+343
View File
@@ -0,0 +1,343 @@
import { execFileSync } from 'node:child_process'
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'
import { writeChangeScope } from './change-scope.ts'
interface Report {
formatVersion: number
repository: { root: string; branch: string | null; upstream: string | null }
input: { base: string; head: string }
resolved: { baseSha: string; headSha: string; mergeBaseSha: string }
paths: { committed: string[]; staged: string[]; unstaged: string[]; untracked: string[] }
}
interface Fixture {
container: string
root: string
origin: string
}
const fixtureRoots: string[] = []
afterEach(() => {
for (const root of fixtureRoots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function git(cwd: string, args: string[], input?: string | Buffer): string {
return execFileSync('git', ['-C', cwd, ...args], {
encoding: 'utf8',
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
input,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim()
}
function gitBytes(cwd: string, args: string[], input?: Buffer): Buffer {
return execFileSync('git', ['-C', cwd, ...args], {
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
input,
stdio: ['pipe', 'pipe', 'pipe'],
})
}
function write(path: string, content: string, mode?: number): void {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, content, mode === undefined ? undefined : { mode })
}
function fixture(worktreeName = 'worktree'): Fixture {
const container = mkdtempSync(join(tmpdir(), 'dsh-change-scope-'))
fixtureRoots.push(container)
const origin = join(container, 'origin.git')
const root = join(container, worktreeName)
const hooks = join(container, 'hooks')
mkdirSync(hooks)
git(container, ['init', '--bare', '--initial-branch=master', origin])
git(container, ['init', '--initial-branch=master', root])
git(root, ['config', 'user.email', 'change-scope@example.com'])
git(root, ['config', 'user.name', 'Change Scope Tests'])
git(root, ['config', 'commit.gpgsign', 'false'])
git(root, ['config', 'core.hooksPath', hooks])
write(join(root, 'README.md'), '# Fixture\n')
git(root, ['add', 'README.md'])
git(root, ['commit', '-m', 'initial'])
git(root, ['remote', 'add', 'origin', origin])
git(root, ['push', '--set-upstream', 'origin', 'master'])
return { container, root, origin }
}
function commit(root: string, path: string, content: string): string {
write(join(root, path), content)
git(root, ['add', '--', path])
git(root, ['commit', '-m', `add ${path}`])
return git(root, ['rev-parse', 'HEAD'])
}
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
}
function jsonReport(root: string, base: string, head?: string): Report {
const args = ['--base', base, '--json']
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<string, string> {
const status = git(root, ['status', '--porcelain=v2', '--branch', '-z'])
return {
status,
head: git(root, ['rev-parse', 'HEAD']),
refs: git(root, ['for-each-ref', '--format=%(refname) %(objectname)']),
index: readFileSync(join(root, '.git/index')).toString('base64'),
config: readFileSync(join(root, '.git/config')).toString('base64'),
}
}
describe('change-scope', () => {
it('uses an explicit base on a fresh branch without a same-name remote and after its first push', () => {
const { root } = fixture()
git(root, ['switch', '-c', 'feature'])
git(root, ['branch', '--set-upstream-to=origin/master'])
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.resolved).toEqual({
baseSha: git(root, ['rev-parse', 'origin/master']),
headSha,
mergeBaseSha: git(root, ['rev-parse', 'origin/master']),
})
expect(fresh.paths).toEqual({ committed: ['feature.txt'], staged: [], unstaged: [], untracked: [] })
expect(git(root, ['for-each-ref', '--format=%(refname)', 'refs/remotes/origin/feature'])).toBe('')
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'])
})
it.skipIf(process.platform === 'win32')('preserves trailing spaces in the worktree path', () => {
const { root } = fixture('worktree ')
const report = jsonReport(root, 'HEAD')
expect(report.repository.root).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'])
const baseSha = commit(root, 'foundation.txt', 'foundation\n')
git(root, ['switch', '-c', 'topic'])
const headSha = commit(root, 'topic.txt', 'topic\n')
commit(root, 'later.txt', 'later\n')
write(join(root, 'current-worktree.txt'), 'current worktree\n')
const report = jsonReport(root, 'foundation', headSha)
expect(report.input).toEqual({ base: 'foundation', head: headSha })
expect(report.resolved).toEqual({ baseSha, headSha, mergeBaseSha: baseSha })
expect(report.paths.committed).toEqual(['topic.txt'])
expect(report.paths.untracked).toEqual(['current-worktree.txt'])
})
it('keeps committed, staged, unstaged, and untracked paths independent and does not mutate state', () => {
const { root } = fixture()
commit(root, 'unstaged.txt', 'before\n')
const baseSha = git(root, ['rev-parse', 'HEAD'])
commit(root, 'committed.txt', 'committed\n')
write(join(root, 'staged.txt'), 'staged\n')
write(join(root, 'mixed.txt'), 'staged part\n')
git(root, ['add', 'staged.txt', 'mixed.txt'])
write(join(root, 'mixed.txt'), 'staged part\nunstaged part\n')
write(join(root, 'unstaged.txt'), 'unstaged\n')
write(join(root, 'untracked.txt'), 'untracked\n')
const before = repositoryState(root)
const report = jsonReport(root, baseSha)
expect(report.paths).toEqual({
committed: ['committed.txt'],
staged: ['mixed.txt', 'staged.txt'],
unstaged: ['mixed.txt', 'unstaged.txt'],
untracked: ['untracked.txt'],
})
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')
const entry = Buffer.from(`100644 ${blobSha}\t`, 'ascii')
const firstPath = Buffer.from([0x80])
const secondPath = Buffer.from([0x81])
gitBytes(root, ['update-index', '-z', '--index-info'], Buffer.concat([
entry,
firstPath,
Buffer.from([0]),
entry,
secondPath,
Buffer.from([0]),
]))
expect(gitBytes(root, ['diff', '--cached', '--name-only', '-z', '--'])).toEqual(Buffer.concat([
firstPath,
Buffer.from([0]),
secondPath,
Buffer.from([0]),
]))
const output: string[] = []
expect(() => {
writeChangeScope(['--base', 'HEAD', '--json'], root, chunk => output.push(chunk))
}).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', () => {
const { root } = fixture()
git(root, ['branch', 'collision'])
git(root, ['tag', 'collision'])
write(join(root, 'blob.txt'), 'blob\n')
const blobSha = git(root, ['hash-object', '-w', 'blob.txt'])
git(root, ['tag', 'blob-ref', blobSha])
for (const { args, message } of [
{ args: ['--base', 'missing'], message: /base ref .* does not resolve to a commit/u },
{ args: ['--base', 'collision'], message: /base ref .* is ambiguous/u },
{ 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)
})
}).toThrow(message)
expect(output).toEqual([])
}
})
it('renders deterministic human and JSON forms with the same facts', () => {
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 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))
})
})
+320
View File
@@ -0,0 +1,320 @@
/** Report the explicit committed and worktree scope of a repository change. */
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { resolve } from 'node:path'
import { parseArgs, TextDecoder } from 'node:util'
const FORMAT_VERSION = 1
const MAX_GIT_OUTPUT = 64 * 1024 * 1024
const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true })
interface ChangeScopeReport {
formatVersion: typeof FORMAT_VERSION
repository: {
root: string
branch: string | null
upstream: string | null
}
input: {
base: string
head: string
}
resolved: {
baseSha: string
headSha: string
mergeBaseSha: string
}
paths: {
committed: string[]
staged: string[]
unstaged: string[]
untracked: string[]
}
}
interface GitCommandResult {
status: number | null
stdout: string
stderr: string
error: Error | undefined
}
interface GitBytesCommandResult {
status: number | null
stdout: Buffer
stderr: Buffer
error: Error | undefined
}
interface ChangeScopeOptions {
base: string
head: string
json: boolean
}
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 {
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`)
}
}
function failureDetail(result: GitCommandResult): string {
return result.error?.message ?? (result.stderr.trim() || `Git exited with status ${String(result.status)}`)
}
function requireGit(cwd: string, args: string[], context: string): string {
const result = executeGit(cwd, args, context)
if (result.status !== 0) throw new Error(`${context}: ${failureDetail(result)}`)
return result.stdout
}
function requireGitBytes(cwd: string, args: string[], context: string): Buffer {
const result = executeGitBytes(cwd, args)
if (result.status !== 0) {
const detail = result.error?.message
?? (result.stderr.toString('utf8').trim() || `Git exited with status ${String(result.status)}`)
throw new Error(`${context}: ${detail}`)
}
return result.stdout
}
function parseOptions(args: string[]): ChangeScopeOptions {
const { values } = parseArgs({
args,
allowPositionals: false,
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 <ref>')
return { base: values.base, head: values.head, json: values.json }
}
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',
'rev-parse',
'--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`)
}
if (result.status !== 0) {
throw new Error(`${label} ref ${JSON.stringify(ref)} does not resolve to a commit: ${failureDetail(result)}`)
}
const commits = result.stdout.trim().split(/\r?\n/u).filter(Boolean)
if (commits.length !== 1) {
throw new Error(`${label} ref ${JSON.stringify(ref)} did not resolve to exactly one commit`)
}
return commits[0] as string
}
function resolveMergeBase(root: string, baseSha: string, headSha: string): string {
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)}`)
}
const mergeBases = result.stdout.trim().split(/\r?\n/u).filter(Boolean)
if (mergeBases.length !== 1) {
throw new Error(`base and head do not have a unique merge base; found ${mergeBases.length}`)
}
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
let record = 0
for (let end = 0; end < output.length; end += 1) {
if (output[end] !== 0) continue
if (end > start) {
record += 1
try {
paths.push(UTF8_DECODER.decode(output.subarray(start, end)))
} catch {
throw new Error(`${context}: Git path ${record} is not valid UTF-8`)
}
}
start = end + 1
}
return [...new Set(paths)].sort(comparePaths)
}
function diffPaths(root: string, args: string[], context: string): string[] {
return parsePathSet(requireGitBytes(root, [
'diff',
'--no-ext-diff',
'--no-textconv',
'--no-renames',
'--ignore-submodules=none',
'--name-only',
'-z',
...args,
'--',
], context), context)
}
function stripGitLineTerminator(output: string): string {
const withoutLineFeed = output.endsWith('\n') ? output.slice(0, -1) : output
return process.platform === 'win32' && withoutLineFeed.endsWith('\r')
? withoutLineFeed.slice(0, -1)
: withoutLineFeed
}
function collectReport(options: ChangeScopeOptions, cwd: string): ChangeScopeReport {
const root = stripGitLineTerminator(
requireGit(cwd, ['rev-parse', '--show-toplevel'], 'cannot locate a Git worktree'),
)
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),
},
input: {
base: options.base,
head: options.head,
},
resolved: {
baseSha,
headSha,
mergeBaseSha,
},
paths: {
committed: diffPaths(root, [mergeBaseSha, headSha], 'cannot inspect committed paths'),
staged: diffPaths(root, ['--cached'], 'cannot inspect staged paths'),
unstaged: diffPaths(root, [], 'cannot inspect unstaged paths'),
untracked: parsePathSet(requireGitBytes(
root,
['ls-files', '--others', '--exclude-standard', '-z', '--'],
'cannot inspect untracked paths',
), 'cannot inspect untracked paths'),
},
}
}
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.
* @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.
*/
export function writeChangeScope(
args: string[],
cwd: string,
write: (output: string) => void,
): void {
const options = parseOptions(args)
const report = collectReport(options, cwd)
write(`${options.json ? JSON.stringify(report, null, 2) : formatHuman(report)}\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))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
process.stderr.write(`change-scope: ${message}\n`)
process.exitCode = 1
}
}