feat(dev-infra): add explicit change scope report
This commit is contained in:
@@ -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: a58bfad840c979fa2290346308471d2c5aede3bd
|
||||
2026-07-27-explicit-change-scope-report.zh.md: ce1bd94a30589d5bdfb0056e3f47a99982a65da2
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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, current branch, configured upstream, input refs, resolved base, head, and merge-base commit IDs, plus sorted committed, staged, unstaged, and untracked path sets.
|
||||
|
||||
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.
|
||||
|
||||
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 stacked non-master base, every dirty layer, 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 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,37 @@
|
||||
# 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,以及排序后的已提交、已暂存、未暂存和未跟踪路径集合。
|
||||
|
||||
已提交路径由解析后的合并基点与头部之间的比较得出。即使 `--head` 指定其他 commit,各类未提交路径集合仍始终描述当前 worktree 与索引。diff 配置不能隐藏子模块,也不能调用外部 diff 或文本转换驱动;系统禁用重命名检测,因此重命名前后的路径都会保留在结果中。
|
||||
|
||||
该命令从不猜测或获取基准,不查询代码托管提供方,也不选择测试。调用该命令的每个工作流都会验证当前远端或堆叠状态、显式提供基准,并将这份事实报告作为语义评审或证据选择的输入。
|
||||
|
||||
聚焦的临时仓库测试覆盖以下情形:新分支跟踪 `origin/master` 但没有同名远端分支;同一分支推送后的上游配置;堆叠分支以非 master 分支为基准;所有未提交改动层;无效、有歧义及不指向 commit 的引用;人类可读输出与 JSON 输出保持确定性一致。测试还确认生成报告前后,引用、索引、配置与状态均不发生变化。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**继续使用临时拼装的 diff 命令,辅以文字化回退说明。** 这种方式无需添加仓库脚本,但不同工作流对常见的新 worktree 与堆叠基准拓扑仍会作出不一致处理,而且无法涵盖未提交改动层。
|
||||
|
||||
**根据配置的上游推断基准。** 第一次推送前,上游可能是 `origin/master`;推送后,它可能是同一功能分支;也可能是一个头部分支,而其 PR 以另一功能分支为基准。没有一种推断能够适用于所有拓扑。
|
||||
|
||||
**在命令内查询 GitHub 以确定基准。** 这会把本地只读报告绑定到单一代码托管平台与网络凭证,却仍然无法解析尚无 PR 的分支。
|
||||
|
||||
**根据变更路径生成必需的测试。** 变更路径无法揭示经由配置、动态加载、子进程、worker、构建产物或提供方触达的行为。pre-push 工作流仍须通过判断来选择证据。
|
||||
|
||||
## 结果
|
||||
|
||||
显式输入仍可能指定错误的基准,但这种错误是可见的:两种输出格式都会显示输入引用与解析出的三个 commit ID。调用方需要付出少量成本,在运行该命令前验证实时基准并从远端获取它。
|
||||
|
||||
仓库需要维护一个 Git 拓扑辅助工具及相应的聚焦测试。由此,pre-push 证据选择、代码评审与文档审计可以共享一份确定且只读的已提交及本地变更说明,而不必混入代码托管平台或策略职责。
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { 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): string {
|
||||
return execFileSync('git', ['-C', cwd, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
|
||||
input,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function write(path: string, content: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, content)
|
||||
}
|
||||
|
||||
function fixture(): Fixture {
|
||||
const container = mkdtempSync(join(tmpdir(), 'dsh-change-scope-'))
|
||||
fixtureRoots.push(container)
|
||||
const origin = join(container, 'origin.git')
|
||||
const root = join(container, 'worktree')
|
||||
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('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('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))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
/** 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 } from 'node:util'
|
||||
|
||||
const FORMAT_VERSION = 1
|
||||
const MAX_GIT_OUTPUT = 64 * 1024 * 1024
|
||||
|
||||
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 ChangeScopeOptions {
|
||||
base: string
|
||||
head: string
|
||||
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' },
|
||||
maxBuffer: MAX_GIT_OUTPUT,
|
||||
})
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
error: result.error,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if (result.status !== 0) throw new Error(`${context}: ${failureDetail(result)}`)
|
||||
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 result = executeGit(root, [
|
||||
'-c',
|
||||
'core.warnAmbiguousRefs=true',
|
||||
'rev-parse',
|
||||
'--verify',
|
||||
'--end-of-options',
|
||||
`${ref}^{commit}`,
|
||||
])
|
||||
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])
|
||||
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'])
|
||||
if (result.status === 1) return null
|
||||
if (result.status !== 0) throw new Error(`cannot inspect the current branch: ${failureDetail(result)}`)
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
function configuredUpstream(root: string, branch: string | null): string | null {
|
||||
if (branch === null) return null
|
||||
const output = requireGit(
|
||||
root,
|
||||
['for-each-ref', '--count=1', '--format=%(upstream:short)', `refs/heads/${branch}`],
|
||||
'cannot inspect the configured upstream',
|
||||
).trim()
|
||||
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: string): string[] {
|
||||
return [...new Set(output.split('\0').filter(Boolean))].sort(comparePaths)
|
||||
}
|
||||
|
||||
function diffPaths(root: string, args: string[], context: string): string[] {
|
||||
return parsePathSet(requireGit(root, [
|
||||
'diff',
|
||||
'--no-ext-diff',
|
||||
'--no-textconv',
|
||||
'--no-renames',
|
||||
'--ignore-submodules=none',
|
||||
'--name-only',
|
||||
'-z',
|
||||
...args,
|
||||
'--',
|
||||
], context))
|
||||
}
|
||||
|
||||
function collectReport(options: ChangeScopeOptions, cwd: string): ChangeScopeReport {
|
||||
const root = requireGit(cwd, ['rev-parse', '--show-toplevel'], 'cannot locate a Git worktree').trim()
|
||||
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(requireGit(
|
||||
root,
|
||||
['ls-files', '--others', '--exclude-standard', '-z', '--'],
|
||||
'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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user