2727 * would land on another record's path — fails the run instead of being
2828 * silently collapsed.
2929 *
30+ * The source's `.gitsheets/` files (configs + vendored contracts) are copied
31+ * with `tx.writeFile` into a detached gitsheets orphan commit that the record
32+ * transaction builds on; the staged tree is then re-committed as the
33+ * parentless snapshot on `snapshot-<seed>-scrubbed`, HEAD pointed at it and a
34+ * same-named tag added (`.github/workflows/snapshot.yml` pushes HEAD's
35+ * branch).
36+ *
3037 * If verification fails the script exits non-zero and no target ref moves
3138 * (the staged objects are left unreferenced).
3239 */
3340
3441import { createHash } from 'node:crypto' ;
3542import { execFile } from 'node:child_process' ;
36- import { mkdir , mkdtemp , readdir , readFile , rm } from 'node:fs/promises' ;
37- import { tmpdir } from 'node:os' ;
38- import { dirname , join , resolve } from 'node:path' ;
43+ import { mkdir , readdir , readFile , rm } from 'node:fs/promises' ;
44+ import { join , resolve } from 'node:path' ;
3945import { promisify } from 'node:util' ;
4046
4147import { faker } from '@faker-js/faker' ;
42- import { getFormat , openRepo , RECORD_PATH_KEY , type BlobHandle , type UpsertResult } from 'gitsheets' ;
48+ import {
49+ getFormat ,
50+ openRepo ,
51+ RECORD_PATH_KEY ,
52+ type BlobHandle ,
53+ type Repository ,
54+ type UpsertResult ,
55+ } from 'gitsheets' ;
4356import { SHEET_REFERENCES } from '@cfp/shared' ;
4457
4558const exec = promisify ( execFile ) ;
@@ -387,6 +400,10 @@ interface ScrubbedRecord {
387400 readonly attachments : Record < string , BlobHandle > | null ;
388401}
389402
403+ /**
404+ * Open the target repo, `git init`-ing it first when absent. Raw git:
405+ * gitsheets opens existing repositories but has no init.
406+ */
390407async function initOrOpenTargetRepo ( targetPath : string ) : Promise < GitRunner > {
391408 await mkdir ( targetPath , { recursive : true } ) ;
392409 const git : GitRunner = ( ...args ) => exec ( 'git' , args , { cwd : targetPath } ) ;
@@ -403,50 +420,29 @@ async function initOrOpenTargetRepo(targetPath: string): Promise<GitRunner> {
403420 return git ;
404421}
405422
406- async function streamToBuffer ( stream : NodeJS . ReadableStream ) : Promise < Buffer > {
407- const chunks : Buffer [ ] = [ ] ;
408- for await ( const chunk of stream ) chunks . push ( Buffer . isBuffer ( chunk ) ? chunk : Buffer . from ( chunk ) ) ;
409- return Buffer . concat ( chunks ) ;
410- }
411-
412423/**
413- * Write a parentless commit holding only the source's `.gitsheets/` configs,
414- * for the record transaction to build on.
424+ * Write a parentless commit holding only the source's `.gitsheets/` files
425+ * (configs plus vendored contracts), for the record transaction to build on:
426+ * an orphan `transact({ parent: null })` staging each file with
427+ * `tx.writeFile`. No branch is named, so the commit is detached and no ref
428+ * moves.
415429 *
416- * Stays on raw git plumbing (hash-object / mktree / commit-tree): gitsheets
417- * 2.7 has no public API for writing non-record files (`tx.writeFile` is
418- * internal) or for orphan commits. Swap once upstream exposes them .
430+ * The configs can't go in the record transaction itself: a transaction's
431+ * sheets load their configs from its parent commit (from HEAD for an orphan),
432+ * never from files staged with `tx.writeFile` in the same transaction .
419433 */
420434async function writeConfigRootCommit (
421- targetPath : string ,
422- configs : ReadonlyArray < { name : string ; content : Buffer } > ,
435+ repo : Repository ,
436+ configs : ReadonlyArray < { path : string ; content : Buffer } > ,
423437) : Promise < string > {
424- // A throwaway index builds the nested `.gitsheets/` tree (configs plus
425- // `contracts/<host>/<path>.toml`) without touching any real index.
426- const indexFile = join ( await mkdtemp ( join ( tmpdir ( ) , 'cfp-scrub-index-' ) ) , 'index' ) ;
427- const indexEnv = { ...process . env , GIT_INDEX_FILE : indexFile } ;
428- for ( const { name, content } of configs ) {
429- const hash = await new Promise < string > ( ( resolvePromise , reject ) => {
430- const child = execFile (
431- 'git' ,
432- [ 'hash-object' , '-w' , '--stdin' ] ,
433- { cwd : targetPath } ,
434- ( err , stdout ) => ( err ? reject ( err ) : resolvePromise ( stdout . trim ( ) ) ) ,
435- ) ;
436- child . stdin ?. end ( content ) ;
437- } ) ;
438- await exec ( 'git' , [ 'update-index' , '--add' , '--cacheinfo' , `100644,${ hash } ,.gitsheets/${ name } ` ] , {
439- cwd : targetPath ,
440- env : indexEnv ,
441- } ) ;
442- }
443- const rootTree = ( await exec ( 'git' , [ 'write-tree' ] , { cwd : targetPath , env : indexEnv } ) ) . stdout . trim ( ) ;
444- await rm ( dirname ( indexFile ) , { recursive : true , force : true } ) ;
445- const { stdout } = await exec ( 'git' , [ 'commit-tree' , rootTree , '-m' , 'scrub-data: sheet configs' ] , {
446- cwd : targetPath ,
447- env : { ...process . env , ...gitIdentityEnv ( new Date ( ) . toISOString ( ) ) } ,
448- } ) ;
449- return stdout . trim ( ) ;
438+ const { commitHash } = await repo . transact (
439+ { parent : null , message : 'scrub-data: sheet configs' , author : SNAPSHOT_AUTHOR } ,
440+ async ( tx ) => {
441+ for ( const { path, content } of configs ) tx . writeFile ( path , content ) ;
442+ } ,
443+ ) ;
444+ if ( commitHash === null ) throw new Error ( 'scrub-data: internal: config root transaction produced no commit' ) ;
445+ return commitHash ;
450446}
451447
452448function gitIdentityEnv ( timestamp : string ) : Record < string , string > {
@@ -556,24 +552,23 @@ export async function scrubRepo(opts: ScrubOptions): Promise<ScrubResult> {
556552 // moves; its tree is re-committed as the orphan snapshot in step 9.
557553 // -------------------------------------------------------------------------
558554 const git = await initOrOpenTargetRepo ( target ) ;
555+ // Raw git: resolves a non-bare target's `.git` for openRepo.
559556 const targetGitDir = ( await git ( 'rev-parse' , '--absolute-git-dir' ) ) . stdout . trim ( ) ;
560557 const targetRepo = await openRepo ( { gitDir : targetGitDir } ) ;
561558
562559 // Everything under the source's `.gitsheets/`: sheet configs plus the
563560 // vendored schema contracts (`contracts/…`) that configs may declare.
564561 const sourceRef = sourceHeadHash ?? 'HEAD' ;
562+ // Raw git: gitsheets reads a blob at a path but has no subtree listing.
565563 const configPaths = (
566564 await exec ( 'git' , [ 'ls-tree' , '-r' , '--name-only' , sourceRef , '.gitsheets/' ] , { cwd : source } )
567565 ) . stdout
568566 . split ( '\n' )
569567 . filter ( ( p ) => p . length > 0 ) ;
570568 const configs = await Promise . all (
571- configPaths . map ( async ( path ) => ( {
572- name : path . slice ( '.gitsheets/' . length ) ,
573- content : await streamToBuffer ( await sourceRepo . readBlobStream ( sourceRef , path ) ) ,
574- } ) ) ,
569+ configPaths . map ( async ( path ) => ( { path, content : await ( await sourceRepo . readBlob ( sourceRef , path ) ) . read ( ) } ) ) ,
575570 ) ;
576- const configRoot = await writeConfigRootCommit ( target , configs ) ;
571+ const configRoot = await writeConfigRootCommit ( targetRepo , configs ) ;
577572
578573 const writtenFiles : SnapshotFile [ ] = [ ] ;
579574 const scrubCounts : Record < string , number > = { } ;
@@ -649,13 +644,22 @@ export async function scrubRepo(opts: ScrubOptions): Promise<ScrubResult> {
649644 // -------------------------------------------------------------------------
650645 // 9. Commit: orphan squashed commit of the staged tree
651646 //
652- // Stays on raw git (commit-tree / update-ref / symbolic-ref / tag):
653- // gitsheets 2.7 can't write a parentless commit. The snapshot workflow
654- // pushes HEAD and tags it, so HEAD must point at the new branch.
647+ // Stays on raw git, each step for want of a gitsheets API:
648+ // - commit-tree: re-committing the staged tree as a parentless commit.
649+ // gitsheets 2.9's orphan transact starts from an empty tree, and its
650+ // sheets can't see configs staged in the same transaction (see
651+ // writeConfigRootCommit), so the records can't be written straight
652+ // into an orphan.
653+ // - update-ref: gitsheets can't point a branch at an existing commit
654+ // (only commit onto one), and a same-seed re-run overwrites the branch.
655+ // - symbolic-ref HEAD: gitsheets never moves HEAD. The snapshot workflow
656+ // pushes the branch HEAD is on, so HEAD must point at the new branch.
657+ // - tag: gitsheets has no tag API.
655658 // -------------------------------------------------------------------------
656659 const timestamp = new Date ( ) . toISOString ( ) ;
657660 const branchName = `snapshot-${ seed . replace ( / [ ^ a - z 0 - 9 ] / gi, '-' ) } -scrubbed` ;
658661
662+ // Raw git: HEAD's tree hash, which gitsheets doesn't expose.
659663 const currentTree = await git ( 'rev-parse' , '--verify' , '--quiet' , 'HEAD^{tree}' )
660664 . then ( ( r ) => r . stdout . trim ( ) )
661665 . catch ( ( ) => null ) ;
@@ -686,7 +690,8 @@ See apps/api/scripts/scrub-data.ts and specs/behaviors/storage.md.`;
686690 await git ( 'symbolic-ref' , 'HEAD' , `refs/heads/${ branchName } ` ) ;
687691 await exec ( 'git' , [ 'tag' , '-f' , branchName , commitHash ] , { cwd : target , env : commitEnv } ) ;
688692
689- // Bring a non-bare target's working tree in line with the new HEAD.
693+ // Bring a non-bare target's working tree in line with the new HEAD. Raw
694+ // git: gitsheets works bare-style and never touches a working tree.
690695 if ( ( await git ( 'rev-parse' , '--is-bare-repository' ) ) . stdout . trim ( ) !== 'true' ) {
691696 for ( const entry of await readdir ( target , { withFileTypes : true } ) ) {
692697 if ( entry . name === '.git' ) continue ;
0 commit comments