diff --git a/CHANGELOG.md b/CHANGELOG.md index 23aa3d1..67032d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Self-contained internal Git commits** - root-set, publication, and vault + commits now carry git-cas-owned author and committer identity, so bare + repositories do not depend on ambient Git user configuration. + ## [6.5.4] — 2026-07-26 ### Added diff --git a/docs/design/0055-internal-commit-identity/internal-commit-identity.md b/docs/design/0055-internal-commit-identity/internal-commit-identity.md new file mode 100644 index 0000000..70d0ce9 --- /dev/null +++ b/docs/design/0055-internal-commit-identity/internal-commit-identity.md @@ -0,0 +1,319 @@ +--- +title: "INFRA-0055 - Internal Commit Identity" +cycle: "0055" +task_id: "internal-commit-identity" +legend: "INFRA" +release_home: "v6.5.5" +issue: "https://github.com/git-stunts/git-cas/issues/102" +goalpost_issue: "none" +tracker_source: "github" +status: "active" +base_commit: "a2d23f5bfc5d00eecab897eadd9072dab4aff534" +owners: + - "@git-stunts" +sponsors: + human: "James" + agent: "Codex" +blocking_issues: [] +supersedes: [] +superseded_by: null +created: "2026-07-26" +updated: "2026-07-26" +--- + +# INFRA-0055 - Internal Commit Identity + +## Linked Issue + +- https://github.com/git-stunts/git-cas/issues/102 + +## Linked Tracker + +- Milestone: `v6.5.5` +- Goalpost issue: none +- Slice issue: https://github.com/git-stunts/git-cas/issues/102 + +## Design Type + +- [ ] Runtime/API +- [x] Storage/substrate +- [x] Migration/release +- [ ] CLI/operator +- [ ] Docs/public guidance +- [ ] TUI/visual surface +- [x] Test/tooling + +## Decision Summary + +Every git-cas-owned commit created through `GitRefAdapter` supplies a stable +internal author and committer name and email to Git. The adapter owns this +metadata; callers and repositories do not need Git user configuration, and +git-cas does not mutate local or global configuration. + +## Sponsored Human + +An operator with a bare retained store wants storage writes to work without +configuring a personal Git identity in that store or on the machine. + +## Sponsored Agent + +An agent needs internal commit creation to have a self-contained, inspectable +contract so it can migrate or verify bare stores without inferring ambient +machine configuration. + +## Hill + +By the end of this cycle, root-set retention succeeds in a bare repository with +no ambient Git identity, and both unit and Git-backed integration tests prove +the exact commit metadata. + +## Current Truth + +`GitRefAdapter.createCommit()` invokes `git commit-tree` with arguments only. +Git therefore consults repository, global, or system identity. A clean Linux +verification of git-warp's retained-substrate migration fails with +`WORKSPACE_RETENTION_FAILED`; the nested Git error is `Author identity unknown`. +Issue #102 records the defect. + +## Problem + +Git identity is process input, not storage authority. Depending on ambient +identity makes identical git-cas operations succeed or fail based on the host +and may leak a human identity into internal storage commits. + +## Scope + +This cycle includes: + +- stable internal author and committer metadata for adapter-created commits; +- a unit proof of the exact Git execution request; +- a real bare-repository root-set proof; +- patch-release notes and git-warp migration verification. + +## Non-Goals + +This cycle does not include: + +- fixed commit timestamps or byte-identical commit OIDs; +- changes to commit messages, trees, refs, or compare-and-swap behavior; +- caller-selectable internal identities; +- mutation of repository, global, or system Git configuration. + +## Runtime / API Contract + +The public API is unchanged. Internally, every `GitRefPort.createCommit()` +request lowered through `GitRefAdapter` executes with: + +- author name: `git-cas` +- author email: `git-cas@example.invalid` +- committer name: `git-cas` +- committer email: `git-cas@example.invalid` + +Caller-provided ambient identity cannot override these values. + +## User Experience / Product Shape + +There is no rendered surface. The operator-visible outcome is that root-set, +publication, and vault writes work in unconfigured bare repositories. + +## Data / State Model + +| State | Source of truth | Derived state | Invalid states | Reset behavior | Serialization | Determinism assumptions | +| --- | --- | --- | --- | --- | --- | --- | +| Internal commit identity | `GitRefAdapter` constants | Commit author and committer headers | Missing or ambient-only identity | Not applicable | Native Git commit headers | Names and emails are stable; timestamps remain runtime-generated | + +## Architecture / Anti-SLUDGE Posture + +| Concern | Decision | +| --- | --- | +| Domain changes | None | +| Port changes | None | +| Adapter changes | Add one immutable identity map at the Git process boundary | +| Boundary validation | Plumbing filters the four permitted Git identity variables | +| Runtime-backed nouns introduced | None | +| Expected failure representation | Existing Git and CAS error contracts remain | +| Banned shortcuts avoided | No config writes, fallback identities, or caller branching | + +## Cost / Residency Posture + +| Surface | Current cost | Target cost | Limit/budget | Failure mode | +| --- | --- | --- | --- | --- | +| Internal commit creation | One `commit-tree` process | Same process with four environment values | No extra Git calls | Existing Git failure normalization | + +## Determinism / Replay / Causality + +Identity strings are deterministic. Commit timestamps remain current-time +metadata, so this cycle does not promise byte-identical commit OIDs. Parentless +root-set generation and compare-and-swap causality are unchanged. + +## Git Substrate Impact + +| Substrate area | Impact | +| --- | --- | +| refs | None | +| commits | Stable git-cas author and committer headers | +| trees/blobs | None | +| object ids | May differ from ambient-identity commits, as expected | +| tag/release behavior | Patch release `v6.5.5` | +| migration compatibility | Unblocks verification in identity-free bare stores | + +## Compatibility / Migration Posture + +| Concern | Decision | +| --- | --- | +| Public API compatibility | Unchanged | +| Package export changes | None | +| Storage/read compatibility | Existing commits remain readable | +| Legacy behavior retained | No ambient identity dependency retained | +| Deprecation behavior | None | +| Migration path | Upgrade git-cas; no data rewrite | +| Release note impact | Fixed in `v6.5.5` | + +## Error Contract + +| Failure | Error/result | Caller recovery | Test | +| --- | --- | --- | --- | +| Ambient identity absent | Commit succeeds with internal identity | None | Root-set integration | +| Git commit fails for another reason | Existing plumbing/CAS error | Address the reported Git failure | Existing adapter and persistence suites | + +## Security / Trust / Redaction Posture + +- trust boundary: git-cas owns metadata for commits under its storage refs; +- authority or capability checked: unchanged ref allowlists and CAS updates; +- secret-bearing values: none; +- redaction behavior: no human name or email is sourced from ambient config; +- log/report behavior: unchanged; +- abuse or replay concern: identity is descriptive, not authorization. + +## Lower Modes + +The machine-readable lower mode is the native Git commit object. Tests inspect +its author and committer headers directly. + +## Accessibility Posture + +No visual interaction changes. The design, changelog, and witness use a linear +text reading order, and the failure/success evidence is available as plain test +output. + +## User-Facing Text / Directionality + +Only changelog and design prose changes. It uses logical sequence language and +has no direction-dependent layout. + +## Agent Inspectability / Explainability Posture + +An agent can inspect the exact environment passed to plumbing in the unit test +and the committed headers through `git show`; no pixel or prose inference is +required. + +## Linked Invariants + +- Internal storage commits do not require caller Git configuration. +- Root-set generations remain parentless and compare-and-swap protected. +- The adapter does not mutate caller repositories beyond the requested objects + and refs. + +## Design Alternatives Considered + +### Option A: Configure every repository + +Rejected because it mutates caller-owned configuration and leaves global/system +precedence questions. + +### Option B: Supply identity only in `RootSetPersistence` + +Rejected because publication and vault commits use the same adapter and have +the same hidden dependency. + +### Option C: Supply identity in `GitRefAdapter` + +Selected because it fixes the dependency once at the internal commit boundary +without changing the port or public API. + +## Decision + +Use option C. Pass an immutable internal identity map on every adapter-owned +`commit-tree` execution. + +## Proof Surface + +- actual surface under test: `GitRefAdapter.createCommit()` and Git-backed + `rootSets.open().put()`; +- first RED test: adapter execution lacked the expected `env` map; +- required witness command: unit suite plus bare root-set integration; +- non-acceptable proof: setting `user.name` or `user.email` in the test repo. + +## Implementation Slices + +- Add failing adapter execution test. +- Add internal identity map and pass it to plumbing. +- Assert native commit headers in the bare-repository integration. +- Validate, publish the patch, and rerun git-warp's Linux migration proof. + +## Tests To Write First + +- [x] Adapter test that fails before the environment map exists. +- [x] Integration assertion against native Git commit headers. +- [x] Regression proof for the exact bare-root-set failure. + +## Acceptance Criteria + +- [x] Adapter behavior supplies all four identity values. +- [x] Bare root-set write records the internal identity. +- [x] Full unit and integration suites are green. +- [x] Changelog and issue are linked. +- [ ] CI is green and the patch package is published. +- [ ] git-warp's clean-Linux migration verification passes on the patch. + +## Validation Plan + +```bash +pnpm vitest run test/unit/infrastructure/adapters/GitRefAdapter.test.js +GIT_STUNTS_DOCKER=1 pnpm vitest run test/integration/root-set.test.js --no-file-parallelism +npx eslint . +npm test +npm run release:verify -- --skip-jsr +``` + +The final integration and release checks run in the repository's supported +container workflow. + +## Playback / Witness + +The witness records the RED adapter assertion, the GREEN unit result, native +commit-header verification, full validation, release evidence, and the +downstream git-warp Linux proof. + +## Risks + +Known risk: + +- internal commit OIDs change because identity is part of commit bytes. + +Mitigation: + +- git-cas treats these as opaque current-generation commits; tests assert + semantic reachability and ref behavior, not historical OIDs. + +## Follow-On Debt + +None currently. New debt must be filed as a GitHub issue. + +## Tracker Disposition + +| Issue | Role | Expected disposition | +| --- | --- | --- | +| https://github.com/git-stunts/git-cas/issues/102 | primary | close when patch release and downstream proof are complete | + +## Done Does Not Mean + +When this lands, it does not prove deterministic timestamps, signed internal +commits, or a general public commit-author API. + +## Retrospective + +The adapter proved to be the narrow ownership boundary: one environment map +covers root-set, publication, and vault commits without repository +configuration or caller coupling. Local cross-runtime verification is green. +PR, release, and downstream verification remain pending. diff --git a/docs/design/0055-internal-commit-identity/witness/verification.md b/docs/design/0055-internal-commit-identity/witness/verification.md new file mode 100644 index 0000000..5502dcb --- /dev/null +++ b/docs/design/0055-internal-commit-identity/witness/verification.md @@ -0,0 +1,66 @@ +# INFRA-0055 verification + +Date: 2026-07-26 + +Issue: https://github.com/git-stunts/git-cas/issues/102 + +## Playback answers + +### Do internal commits require an ambient Git identity? + +No. `GitRefAdapter.createCommit()` supplies a git-cas-owned author and +committer identity to `git commit-tree` through the command environment: + +```text +git-cas +``` + +The adapter unit test observes all four environment variables. The +Git-backed root-set integration reads the resulting commit with native Git +and confirms both author and committer headers. + +### Does the fix mutate repository or global configuration? + +No. The implementation passes identity only to the individual plumbing +operation. It does not invoke `git config`, persist user settings, or require +callers to supply personal identity. + +### What failed before the fix? + +A clean Linux migration rehearsal opened an authoritative bare repository +without `user.name` or `user.email`. Post-promotion root-set retention reached +`git commit-tree`, which rejected the write with `Author identity unknown`. +The first adapter test was RED because the plumbing request contained no +environment map. + +## Commands + +```text +pnpm vitest run test/unit/infrastructure/adapters/GitRefAdapter.test.js + RED: expected identity environment, received none + GREEN: 16 passed + +GIT_STUNTS_DOCKER=1 pnpm vitest run \ + test/integration/root-set.test.js --no-file-parallelism + 3 passed + +pnpm test + 2,087 passed, 2 skipped + +pnpm exec eslint . + passed + +pnpm test:integration:node + 199 passed +``` + +The final `release:verify -- --skip-jsr` run passed all 13 executed gates: +6,847 tests across Node, Bun, and Deno unit/integration suites, public type +compatibility, examples, lint, build stamping, and the npm pack dry-run. +The dry-run package was 785,225 bytes compressed and 2,210,241 bytes unpacked. + +## Remaining witness + +After this patch is published, git-warp must consume it and rerun the exact +clean-Linux retained-substrate migration proof. Issue #102 remains open until +that downstream gate passes. diff --git a/docs/design/README.md b/docs/design/README.md index c962d54..29f6afa 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -11,6 +11,7 @@ process in [docs/method/process.md](../method/process.md). ## Active METHOD Cycles +- [0055-internal-commit-identity - internal-commit-identity](./0055-internal-commit-identity/internal-commit-identity.md) - [0054-batched-page-retention - batched-page-retention](./0054-batched-page-retention/batched-page-retention.md) - [0050-lazy-bundle-reference-reads - lazy-bundle-reference-reads](./0050-lazy-bundle-reference-reads/lazy-bundle-reference-reads.md) - [0049-scoped-staging-workspaces — scoped-staging-workspaces](./0049-scoped-staging-workspaces/scoped-staging-workspaces.md) diff --git a/src/infrastructure/adapters/GitRefAdapter.js b/src/infrastructure/adapters/GitRefAdapter.js index 6fb3feb..86528c8 100644 --- a/src/infrastructure/adapters/GitRefAdapter.js +++ b/src/infrastructure/adapters/GitRefAdapter.js @@ -14,6 +14,12 @@ import Oid from '../../domain/value-objects/Oid.js'; */ const DEFAULT_POLICY = Policy.timeout(30_000); const FORBIDDEN_REF_CHARACTERS = new Set(['~', '^', ':', '?', '*', '[', '\\']); +const INTERNAL_COMMIT_IDENTITY = Object.freeze({ + GIT_AUTHOR_EMAIL: 'git-cas@example.invalid', + GIT_AUTHOR_NAME: 'git-cas', + GIT_COMMITTER_EMAIL: 'git-cas@example.invalid', + GIT_COMMITTER_NAME: 'git-cas', +}); const MAX_REF_ITERATION_LIMIT = 1001; const REF_NAME_ENCODER = new globalThis.TextEncoder(); @@ -103,7 +109,7 @@ export default class GitRefAdapter extends GitRefPort { args.push('-p', parent); } return this.policy.execute(() => - this.plumbing.execute({ args }), + this.plumbing.execute({ args, env: INTERNAL_COMMIT_IDENTITY }), ); } diff --git a/test/integration/root-set.test.js b/test/integration/root-set.test.js index f23f357..ca76278 100644 --- a/test/integration/root-set.test.js +++ b/test/integration/root-set.test.js @@ -76,6 +76,9 @@ describe('RootSet Git reachability', () => { expect(prunableOids()).not.toContain(targetTreeOid); expect(git(['cat-file', '-p', ROOT_SET_REF])).not.toMatch(/^parent /m); + expect(git(['show', '-s', '--format=%an <%ae>%n%cn <%ce>', ROOT_SET_REF])).toBe( + 'git-cas \ngit-cas ', + ); }); it('makes a removed and otherwise-unreferenced tree prunable', async () => { diff --git a/test/unit/infrastructure/adapters/GitRefAdapter.test.js b/test/unit/infrastructure/adapters/GitRefAdapter.test.js index c401190..ef106ca 100644 --- a/test/unit/infrastructure/adapters/GitRefAdapter.test.js +++ b/test/unit/infrastructure/adapters/GitRefAdapter.test.js @@ -77,6 +77,30 @@ describe('GitRefAdapter.resolveParents()', () => { }); }); +describe('GitRefAdapter.createCommit()', () => { + it('supplies internal identity instead of depending on ambient Git config', async () => { + const { adapter, plumbing } = createAdapter(); + const treeOid = 'a'.repeat(40); + plumbing.execute.mockResolvedValueOnce('b'.repeat(40)); + + await adapter.createCommit({ + treeOid, + parentOid: null, + message: 'root-set: replace current roots', + }); + + expect(plumbing.execute).toHaveBeenCalledWith({ + args: ['commit-tree', treeOid, '-m', 'root-set: replace current roots'], + env: { + GIT_AUTHOR_EMAIL: 'git-cas@example.invalid', + GIT_AUTHOR_NAME: 'git-cas', + GIT_COMMITTER_EMAIL: 'git-cas@example.invalid', + GIT_COMMITTER_NAME: 'git-cas', + }, + }); + }); +}); + // eslint-disable-next-line max-lines-per-function describe('GitRefAdapter.updateRef()', () => { it('uses Git create-only CAS semantics when expectedOldOid is null', async () => {