feat(freeze): `crafter-review freeze`, and --verify that names a git upgrade
Freeze: identity, AI trailers by the consumer's rule (the pattern with
case-insensitive folds and its \s* that crosses lines), a clean tree, no
merges, a fast-forward, then format-patch, the hash, the dispositions
register and the optional detached worktree; --verify at push time. The
command line takes --opt=value, unique prefixes and the last of a repeat,
with a usage error at exit 3 like every command that cannot start; the
config is loaded and its warnings and problems reported first. The run
line it prints names this binary.
freeze.json records gitVersion. format-patch ends every patch with the
git version, so a fresh export under another git hashes differently;
--verify's MISMATCH says which git exported the artifact and which the
fresh copy, reading the patches' own signature when freeze.json does not
record it. freeze becomes a subreaper and takes down whatever gpg left
running for %G?.
tests/Freeze: 18 checks, plus the gitVersion record and both MISMATCH
messages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 07:03:48 +01:00
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Freeze: the checks (authors, trailers by rule, allowDirty, the config
// base), the export, the run line, verify, on a scratch repository, and a
// MISMATCH that comes from a change of git version saying so.
# include <stdlib.h>
import std ;
import Crafter . Review ;
namespace fs = std : : filesystem ;
using namespace Crafter : : Review ;
namespace {
std : : int32_t Failures = 0 ;
void Check ( std : : string_view name , bool cond , std : : string_view detail = { } ) {
std : : println ( " {}{}{} " , cond ? " ok " : " FAIL " , name , ! cond & & ! detail . empty ( ) ? std : : format ( " ({}) " , detail ) : std : : string ( ) ) ;
if ( ! cond ) + + Failures ;
}
std : : string Joined ( const Json & list ) {
std : : vector < std : : string > parts ;
for ( const Json & item : list ) parts . push_back ( item . get < std : : string > ( ) ) ;
return Dyn : : Join ( parts , " | " ) ;
}
bool AnyContains ( const Json & list , std : : string_view part ) {
return std : : ranges : : any_of ( list , [ & ] ( const Json & item ) { return item . get < std : : string > ( ) . contains ( part ) ; } ) ;
}
bool AnyContains ( const std : : vector < std : : string > & list , std : : string_view part ) {
return std : : ranges : : any_of ( list , [ & ] ( const std : : string & item ) { return item . contains ( part ) ; } ) ;
}
}
int main ( ) {
std : : println ( " freeze: checks (authors, trailers by rule, allowDirty, the config base), export, run line, verify on a temp repo " ) ;
// No git of this test reads a global config: this machine's signs every
// commit with the owner's key.
setenv ( " GIT_CONFIG_GLOBAL " , " /dev/null " , 1 ) ;
setenv ( " GIT_CONFIG_SYSTEM " , " /dev/null " , 1 ) ;
const fs : : path td = fs : : temp_directory_path ( ) / std : : format ( " crafter-review-freeze-{} " , Platform : : PublishedPid ( ) ) ;
fs : : remove_all ( td ) ;
const fs : : path repo = td / " r " ;
fs : : create_directories ( repo ) ;
const std : : string me = " me@example.com " ;
std : : map < std : : string , std : : string > env = Platform : : Environment ( ) ;
env [ " GIT_AUTHOR_NAME " ] = " Me " ;
env [ " GIT_AUTHOR_EMAIL " ] = me ;
env [ " GIT_COMMITTER_NAME " ] = " Me " ;
env [ " GIT_COMMITTER_EMAIL " ] = me ;
auto g = [ & ] ( std : : vector < std : : string > args ) {
std : : vector < std : : string > argv = { " git " , " -C " , repo . string ( ) } ;
std : : ranges : : move ( args , std : : back_inserter ( argv ) ) ;
const Process : : Captured run = Process : : Run ( argv , std : : nullopt , std : : nullopt , { } , env ) ;
if ( run . returnCode ! = 0 ) throw std : : runtime_error ( std : : format ( " {}: {} " , Dyn : : Join ( argv , " " ) , run . err ) ) ;
return Dyn : : Strip ( run . out ) ;
} ;
auto commit = [ & ] ( const std : : string & message , std : : string_view text , const std : : string & path = " a.txt " , const std : : string & author = { } ) {
std : : ofstream ( repo / path , std : : ios : : app ) < < text ;
g ( { " add " , path } ) ;
std : : vector < std : : string > args = { " commit " , " -q " , " -m " , message } ;
if ( ! author . empty ( ) ) args . push_back ( std : : format ( " --author={} " , author ) ) ;
g ( args ) ;
return g ( { " rev-parse " , " HEAD " } ) ;
} ;
g ( { " init " , " -q " , " -b " , " main " } ) ;
g ( { " config " , " user.email " , me } ) ;
g ( { " config " , " user.name " , " Me " } ) ;
const std : : string base = commit ( " base " , " one \n " ) ;
g ( { " branch " , " dev " } ) ;
const std : : string clean = commit ( " clean change " , " two \n " ) ;
commit ( " with trailer \n \n Co-Authored-By: Claude <noreply@anthropic.com> " , " three \n " ) ;
commit ( " foreign " , " four \n " , " a.txt " , " Cat Bot <catbot@bot.local> " ) ;
const fs : : path out = td / " out " ;
auto freeze = [ & ] ( Freeze : : Options options ) {
options . cwd = td ;
if ( options . date . empty ( ) ) options . date = " 2026-10-31 " ;
return Freeze : : Run ( repo , out , options ) ;
} ;
Json r = freeze ( { . base = base } ) ;
std : : string failures = Joined ( r . at ( " failures " ) ) ;
Check ( " trailer (forbid) + foreign author both named, nothing exported; the author list came from git config " , ! r . at ( " ok " ) . get < bool > ( ) & & failures . contains ( " Co-Authored-By " )
& & failures . contains ( " catbot@bot.local " ) & & ! fs : : exists ( out ) & & r . at ( " authors " ) = = Json : : array ( { me } ) , failures ) ;
r = freeze ( { . base = base , . aiTrailers = " allow " } ) ;
Check ( " trailer (allow): a warning, the foreign author still fails " , ! r . at ( " ok " ) . get < bool > ( ) & & Joined ( r . at ( " failures " ) ) . contains ( " catbot " )
& & ! Joined ( r . at ( " failures " ) ) . contains ( " Co-Authored-By " ) & & AnyContains ( r . at ( " warnings " ) , " Co-Authored-By " ) ) ;
r = freeze ( { . base = base , . authors = { me , " catbot@bot.local " } , . aiTrailers = " allow " } ) ;
Check ( " both emails in authors + allow -> the range freezes (3 commits) " , r . at ( " ok " ) . get < bool > ( ) & & r . at ( " files " ) . size ( ) = = 3 , Joined ( r . at ( " failures " ) ) ) ;
g ( { " reset " , " -q " , " --hard " , clean } ) ;
r = freeze ( { . base = base , . replace = true } ) ;
Check ( " clean range: exported 1 file, hash = artifact_hash, head = HEAD, register created, base from --base " , r . at ( " ok " ) . get < bool > ( )
& & r . at ( " files " ) = = Json : : array ( { " 0001-clean-change.patch " } ) & & r . at ( " hash " ) = = Artifact : : ArtifactHash ( " commits " , out ) & & r . at ( " head " ) = = clean & & Artifact : : PatchHead ( out ) = = clean
& & Dyn : : LoadJsonFile ( out / " gate-state " / " dispositions.json " ) = = Json : : array ( ) & & r . at ( " baseFrom " ) = = " --base " , Joined ( r . at ( " failures " ) ) ) ;
Check ( " no upstream -> warning only; unsigned -> warning, failure with require_signed " , AnyContains ( r . at ( " warnings " ) , " no upstream " ) & & AnyContains ( r . at ( " warnings " ) , " not GPG-signed " )
& & ! freeze ( { . base = base , . replace = true , . requireSigned = true } ) . at ( " ok " ) . get < bool > ( ) ) ;
Check ( " re-freeze without --replace refused " , ! freeze ( { . base = base } ) . at ( " ok " ) . get < bool > ( ) ) ;
const Json r0 = freeze ( { . replace = true } ) ;
Check ( " no --base, no upstream, no config base -> a failure that says so " , ! r0 . at ( " ok " ) . get < bool > ( ) & & r0 . at ( " failures " ) . at ( 0 ) . get < std : : string > ( ) . contains ( " no base in .review-gates/config.json " ) ) ;
Json r1 = freeze ( { . replace = true , . configBase = " dev " } ) ;
Check ( " no upstream: the merge-base with the config base is the base " , r1 . at ( " ok " ) . get < bool > ( ) & & r1 . at ( " base " ) = = base & & r1 . at ( " baseFrom " ) . get < std : : string > ( ) . starts_with ( " merge-base HEAD dev " ) ) ;
g ( { " checkout " , " -q " , " -b " , " topic " } ) ;
g ( { " remote " , " add " , " origin " , repo . string ( ) } ) ;
g ( { " update-ref " , " refs/remotes/origin/topic " , clean } ) ;
g ( { " branch " , " --set-upstream-to=origin/topic " , " topic " } ) ;
commit ( " more " , " more \n " ) ;
const Json ru = freeze ( { . replace = true , . configBase = " dev " } ) ;
const Json rb = freeze ( { . replace = true , . configBase = " dev " , . rangeFrom = " base " } ) ;
Check ( " rangeFrom: upstream = the push delta (1 commit from origin/topic); base = the whole branch from the merge-base with dev (2 commits), with a warning that it is not the delta " ,
ru . at ( " ok " ) . get < bool > ( ) & & ru . at ( " baseFrom " ) = = " @{upstream} " & & ru . at ( " files " ) . size ( ) = = 1 & & rb . at ( " ok " ) . get < bool > ( )
& & rb . at ( " baseFrom " ) . get < std : : string > ( ) . starts_with ( " merge-base HEAD dev (config rangeFrom base) " ) & & rb . at ( " files " ) . size ( ) = = 2 & & rb . at ( " base " ) = = base
& & AnyContains ( rb . at ( " warnings " ) , " whole range " ) , DumpCompactSorted ( { Dyn : : Get ( ru , " baseFrom " ) , Dyn : : Get ( rb , " baseFrom " ) , Dyn : : Get ( rb , " failures " ) } ) ) ;
g ( { " checkout " , " -q " , " main " } ) ;
g ( { " branch " , " -D " , " topic " } ) ;
g ( { " update-ref " , " -d " , " refs/remotes/origin/topic " } ) ;
g ( { " remote " , " remove " , " origin " } ) ;
r1 = freeze ( { . replace = true , . configBase = " dev " } ) ;
const std : : string line = Freeze : : RunLine ( r1 , " out " , " . " ) ;
Check ( " run line: built from the report, names the hash, the base, the tree and the register " , line . starts_with ( std : : format ( " {} run --artifact out --hash {} " , Invocation ( ) , r1 . at ( " hash " ) . get < std : : string > ( ) ) )
& & line . contains ( std : : format ( " --base {} " , base ) ) & & line . contains ( " --trees . " ) & & line . contains ( " out/gate-state/dispositions.json " ) ) ;
const Json r2 = freeze ( { . base = base , . replace = true , . worktree = true } ) ;
const fs : : path wt = td / Dyn : : Str ( Dyn : : Get ( r2 , " worktree " ) ) ;
Check ( " --worktree checks out HEAD detached under .review-gates/trees " , r2 . at ( " ok " ) . get < bool > ( ) & & r2 . at ( " worktree " ) . get < std : : string > ( ) . starts_with ( std : : format ( " {}/r- " , Freeze : : Worktrees ) )
& & Artifact : : TreeHead ( wt ) = = clean & & Artifact : : IsToplevel ( wt ) , Joined ( r2 . at ( " failures " ) ) ) ;
std : : vector < std : : string > problems = Freeze : : Verify ( repo , out ) ;
Check ( " verify: MATCH right after the freeze " , problems . empty ( ) , Dyn : : Join ( problems , " ; " ) ) ;
Check ( " freeze.json records the git that exported the patches " , Dyn : : LoadJsonFile ( out / " freeze.json " ) . at ( " gitVersion " ) = = Freeze : : GitVersion ( ) & & ! Freeze : : GitVersion ( ) . empty ( ) ) ;
std : : ofstream ( repo / " a.txt " , std : : ios : : app ) < < " dirty \n " ;
const std : : vector < std : : string > allowA = { " a.txt " } ;
Check ( " dirty tree: freeze refuses, verify mismatches " , ! freeze ( { . base = base , . replace = true } ) . at ( " ok " ) . get < bool > ( ) & & ! Freeze : : Verify ( repo , out ) . empty ( ) ) ;
const Json rd = freeze ( { . base = base , . replace = true , . allowDirty = allowA } ) ;
Check ( " allowDirty: the dirty path is allowed with a warning; verify passes with it too " , rd . at ( " ok " ) . get < bool > ( ) & & AnyContains ( rd . at ( " warnings " ) , " allowDirty " )
& & Freeze : : Verify ( repo , out , allowA ) . empty ( ) & & ! Freeze : : Verify ( repo , out ) . empty ( ) ) ;
const auto [ counted , allowed ] = Freeze : : DirtyPaths ( repo , allowA ) ;
Check ( " dirty_paths splits counted and allowed " , allowed . size ( ) = = 1 & & Dyn : : Slice ( allowed [ 0 ] , 3 ) = = " a.txt " & & counted . empty ( ) ) ;
g ( { " checkout " , " -q " , " -- " , " a.txt " } ) ;
// A git upgrade between the freeze and the push: the artifact signed by
// another version, its hash and freeze.json made to agree with it.
{
const std : : string now = Freeze : : GitVersion ( ) ;
std : : string all ;
for ( const std : : string & name : Artifact : : PatchFiles ( out ) ) {
std : : string text = * Platform : : ReadFile ( out / name ) ;
text = Dyn : : Replace ( text , std : : format ( " -- \n {} \n " , now ) , " -- \n 1.0.0 \n " ) ;
std : : ofstream ( out / name , std : : ios : : binary | std : : ios : : trunc ) < < text ;
}
Json frozen = Dyn : : LoadJsonFile ( out / " freeze.json " ) ;
frozen [ " hash " ] = Artifact : : ArtifactHash ( " commits " , out ) ;
frozen [ " gitVersion " ] = " 1.0.0 " ;
std : : ofstream ( out / " freeze.json " , std : : ios : : binary | std : : ios : : trunc ) < < DumpFile ( frozen ) ;
problems = Freeze : : Verify ( repo , out ) ;
Check ( " verify after a git upgrade: the MISMATCH says which git exported the artifact and which the fresh copy " , problems . size ( ) = = 1
& & problems [ 0 ] . contains ( std : : format ( " git 1.0.0 exported the artifact and git {} the fresh copy " , now ) ) , Dyn : : Join ( problems , " ; " ) ) ;
frozen . erase ( " gitVersion " ) ;
std : : ofstream ( out / " freeze.json " , std : : ios : : binary | std : : ios : : trunc ) < < DumpFile ( frozen ) ;
problems = Freeze : : Verify ( repo , out ) ;
Check ( " ...and a freeze.json without gitVersion: the patches' signature names it " , problems . size ( ) = = 1 & & problems [ 0 ] . contains ( " git 1.0.0 exported the artifact " ) ,
Dyn : : Join ( problems , " ; " ) ) ;
freeze ( { . base = base , . replace = true } ) ;
}
g ( { " commit " , " -q " , " --amend " , " -m " , " clean change, amended " } ) ;
problems = Freeze : : Verify ( repo , out ) ;
Check ( " verify after an amend: MISMATCH names HEAD and the export " , ! problems . empty ( ) & & AnyContains ( problems , " HEAD " ) & & AnyContains ( problems , " fresh export " ) ) ;
g ( { " checkout " , " -q " , " -b " , " side " , base } ) ;
commit ( " side " , " side \n " , " b.txt " ) ;
g ( { " checkout " , " -q " , " main " } ) ;
g ( { " merge " , " -q " , " --no-ff " , " -m " , " merge side " , " side " } ) ;
const Json r3 = freeze ( { . base = base , . replace = true } ) ;
Check ( " merge in the range refused " , ! r3 . at ( " ok " ) . get < bool > ( ) & & AnyContains ( r3 . at ( " failures " ) , " merge commit " ) ) ;
feat(run): the repository under review is the freeze record's, not the working directory's
A directory can review repositories other than itself: a workbench whose
config, env file and records serve several, freezing each with --repo and
--out. The run took the working directory for the repository under review:
the prompt named it, the calibration was kept under it, and --trees fell
back to it. The run now reads the repository from the artifact's
freeze.json - the prompt names it and the calibration is kept under it -
and --trees defaults to what the record says: the worktree, else the
repository when it is not the working directory, else `.`, the same rule
that builds the printed run line. The working directory keeps its roles:
the config, the env file, the slots, the lens processes' cwd and every
logged path. A damaged record stops the run at preflight.
2026-10-31 21:31:48 +01:00
// What a run reads from the record: the repository under review and the
// tree the lenses read (a workbench freezing another repository with
// --repo: the record names it, not the working directory).
const Json withTree = { { " repo " , repo . string ( ) } , { " worktree " , " x/y " } } ;
const Json repoOnly = { { " repo " , repo . string ( ) } } ;
Check ( " TreeOf: the record's worktree, else the repository when it is not the working directory, else `.` " ,
Freeze : : TreeOf ( withTree , td ) = = " x/y " & & Freeze : : TreeOf ( repoOnly , td ) = = repo . string ( ) & & Freeze : : TreeOf ( repoOnly , repo ) = = " . " & & Freeze : : TreeOf ( Json : : object ( ) , td ) = = " . " ) ;
const Freeze : : Reviewed byRecord = Freeze : : ReviewedBy ( out , td ) ;
const fs : : path none = td / " no-record " ;
fs : : create_directories ( none ) ;
const Freeze : : Reviewed byNone = Freeze : : ReviewedBy ( none , td ) ;
std : : ofstream ( none / " freeze.json " , std : : ios : : binary ) < < " {broken " ;
bool damaged = false ;
try {
Freeze : : ReviewedBy ( none , td ) ;
} catch ( const std : : runtime_error & e ) {
damaged = std : : string ( e . what ( ) ) . contains ( " not a freeze record " ) ;
}
Check ( " ReviewedBy: the record's repository and its tree; no record: the working directory and `.`; a damaged record throws, saying so " ,
fs : : weakly_canonical ( byRecord . repo ) = = fs : : weakly_canonical ( repo ) & & byRecord . tree = = Dyn : : AbsPath ( repo . string ( ) ) & & Freeze : : ReviewedBy ( out , repo ) . tree = = " . " & & byNone . repo = = td
& & byNone . tree = = " . " & & damaged ,
std : : format ( " {} {} {} " , byRecord . repo . string ( ) , byRecord . tree , byNone . tree ) ) ;
feat(freeze): `crafter-review freeze`, and --verify that names a git upgrade
Freeze: identity, AI trailers by the consumer's rule (the pattern with
case-insensitive folds and its \s* that crosses lines), a clean tree, no
merges, a fast-forward, then format-patch, the hash, the dispositions
register and the optional detached worktree; --verify at push time. The
command line takes --opt=value, unique prefixes and the last of a repeat,
with a usage error at exit 3 like every command that cannot start; the
config is loaded and its warnings and problems reported first. The run
line it prints names this binary.
freeze.json records gitVersion. format-patch ends every patch with the
git version, so a fresh export under another git hashes differently;
--verify's MISMATCH says which git exported the artifact and which the
fresh copy, reading the patches' own signature when freeze.json does not
record it. freeze becomes a subreaper and takes down whatever gpg left
running for %G?.
tests/Freeze: 18 checks, plus the gitVersion record and both MISMATCH
messages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 07:03:48 +01:00
g ( { " worktree " , " remove " , " --force " , wt . string ( ) } ) ;
fs : : remove_all ( td ) ;
return Failures ? 1 : 0 ;
}