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®
module Crafter . Review : Freeze_impl ;
import : Freeze ;
import : Json ;
import : Text ;
import : Core ;
import : Crypto ;
import : Artifact ;
import : Config ;
import : Platform ;
import : Process ;
import std ;
namespace fs = std : : filesystem ;
namespace Crafter : : Review : : Freeze {
namespace {
struct GitRun {
std : : int32_t code = 0 ;
std : : string out ;
std : : string err ;
} ;
// git -C <repo> ..., both outputs captured as text: UTF-8 with
// universal newlines.
GitRun Git ( const std : : string & repo , std : : vector < std : : string > args ) {
std : : vector < std : : string > argv = { " git " , " -C " , repo } ;
std : : ranges : : move ( args , std : : back_inserter ( argv ) ) ;
const Process : : Captured run = Process : : Run ( argv ) ;
return { run . returnCode , Dyn : : UniversalNewlines ( Dyn : : DecodeReplace ( run . out ) ) , Dyn : : UniversalNewlines ( Dyn : : DecodeReplace ( run . err ) ) } ;
}
// An ASCII word at `at`, case-insensitively as re.I reads it.
bool WordAt ( std : : u32string_view text , std : : size_t at , std : : string_view word ) {
if ( at + word . size ( ) > text . size ( ) ) return false ;
for ( std : : size_t i = 0 ; i < word . size ( ) ; + + i ) {
const char p = word [ i ] ;
if ( p > = ' a ' & & p < = ' z ' ) {
if ( Dyn : : IgnoreCaseLetter ( text [ at + i ] ) ! = p ) return false ;
} else if ( text [ at + i ] ! = static_cast < char32_t > ( p ) ) {
return false ;
}
}
return true ;
}
// AI_TRAILER.search(body).group(0), the pattern
// ^\s*(assisted-by|co-authored-by)\s*: with re.I and re.M: the first
// line start from which whitespace (newlines too), the word,
// whitespace and a colon follow.
std : : optional < std : : string > AiTrailer ( std : : string_view body ) {
const std : : u32string text = Dyn : : Decode ( body ) ;
for ( std : : size_t start = 0 ; start < = text . size ( ) ; ) {
std : : size_t at = start ;
while ( at < text . size ( ) & & Dyn : : IsSpace ( text [ at ] ) ) + + at ;
for ( const std : : string_view word : { std : : string_view ( " assisted-by " ) , std : : string_view ( " co-authored-by " ) } ) {
if ( ! WordAt ( text , at , word ) ) continue ;
std : : size_t end = at + word . size ( ) ;
while ( end < text . size ( ) & & Dyn : : IsSpace ( text [ end ] ) ) + + end ;
if ( end < text . size ( ) & & text [ end ] = = U ' : ' ) return Dyn : : Encode ( std : : u32string_view ( text ) . substr ( start , end + 1 - start ) ) ;
}
const std : : size_t newline = text . find ( U ' \n ' , start ) ;
if ( newline = = std : : u32string : : npos ) break ;
start = newline + 1 ;
}
return std : : nullopt ;
}
std : : string Head12 ( std : : string_view text ) {
return Dyn : : Slice ( text , 0 , 12 ) ;
}
std : : vector < std : : string > Strings ( const Json & list ) {
std : : vector < std : : string > out ;
if ( ! list . is_array ( ) ) return out ;
for ( const Json & item : list ) out . push_back ( Dyn : : Str ( item ) ) ;
return out ;
}
// The version format-patch signed the artifact's last patch with: the
// line after its last `-- `.
std : : string PatchGitVersion ( const fs : : path & out ) {
const std : : vector < std : : string > files = Artifact : : PatchFiles ( out ) ;
if ( files . empty ( ) ) return " " ;
const std : : optional < std : : string > bytes = Platform : : ReadFile ( out / files . back ( ) ) ;
if ( ! bytes ) return " " ;
const std : : vector < std : : string > lines = Dyn : : Split ( Dyn : : UniversalNewlines ( Dyn : : DecodeReplace ( * bytes ) ) , " \n " ) ;
for ( std : : size_t i = lines . size ( ) ; i - - > 1 ; ) {
if ( lines [ i - 1 ] = = " -- " ) return Dyn : : Strip ( lines [ i ] ) ;
}
return " " ;
}
// tempfile.TemporaryDirectory(): a fresh directory, removed again.
class TemporaryDirectory {
public :
TemporaryDirectory ( ) {
for ( ; ; ) {
path = fs : : temp_directory_path ( ) / std : : format ( " tmp{} " , TokenHex ( 4 ) ) ;
if ( fs : : create_directory ( path ) ) break ;
}
fs : : permissions ( path , fs : : perms : : owner_all , fs : : perm_options : : replace ) ;
}
~ TemporaryDirectory ( ) {
std : : error_code error ;
fs : : remove_all ( path , error ) ;
}
TemporaryDirectory ( const TemporaryDirectory & ) = delete ;
TemporaryDirectory & operator = ( const TemporaryDirectory & ) = delete ;
fs : : path path ;
} ;
}
std : : string GitVersion ( ) {
try {
const Process : : Captured run = Process : : Run ( { " git " , " --version " } ) ;
const std : : string text = Dyn : : Strip ( Dyn : : DecodeReplace ( run . out ) ) ;
constexpr std : : string_view Prefix = " git version " ;
return run . returnCode = = 0 & & text . starts_with ( Prefix ) ? text . substr ( Prefix . size ( ) ) : std : : string ( ) ;
} catch ( const std : : exception & ) {
return " " ;
}
}
std : : vector < Commit > CommitsIn ( const fs : : path & repo , std : : string_view base ) {
const GitRun log = Git ( repo . string ( ) , { " log " , " --reverse " , " --format=%H%x00%ae%x00%ce%x00%G?%x00%s%x00%B%x1e " , std : : format ( " {}..HEAD " , base ) } ) ;
if ( log . code ! = 0 ) throw Dyn : : Error ( " RuntimeError " , Dyn : : Strip ( log . err ) ) ;
std : : vector < Commit > out ;
for ( const std : : string & record : Dyn : : Split ( log . out , " \x1e " ) ) {
if ( Dyn : : Strip ( record ) . empty ( ) ) continue ;
const std : : vector < std : : string > fields = Dyn : : Split ( Dyn : : Strip ( record , " \n " ) , std : : string_view ( " \0 " , 1 ) , 5 ) ;
if ( fields . size ( ) ! = 6 ) throw Dyn : : Error ( " ValueError " , std : : format ( " not enough values to unpack (expected 6, got {}) " , fields . size ( ) ) ) ;
out . push_back ( { fields [ 0 ] , fields [ 1 ] , fields [ 2 ] , fields [ 3 ] , fields [ 4 ] , fields [ 5 ] } ) ;
}
return out ;
}
std : : pair < std : : vector < std : : string > , std : : vector < std : : string > > DirtyPaths ( const fs : : path & repo , std : : span < const std : : string > allowDirty ) {
const GitRun status = Git ( repo . string ( ) , { " status " , " --porcelain " , " --untracked-files=no " } ) ;
std : : vector < std : : string > counted ;
std : : vector < std : : string > allowed ;
for ( const std : : string & line : Dyn : : Split ( Dyn : : RStrip ( status . out , " \n " ) , " \n " ) ) {
if ( Dyn : : Strip ( line ) . empty ( ) ) continue ;
// `XY path`: the two status columns and a space, not stripped: a
// leading space is a column.
const std : : string path = Dyn : : Strip ( Dyn : : Slice ( line , 3 ) ) ;
( std : : ranges : : contains ( allowDirty , path ) ? allowed : counted ) . push_back ( line ) ;
}
return { counted , allowed } ;
}
Base ResolveBase ( const fs : : path & repoPath , std : : string_view base , const std : : optional < std : : string > & configBase , std : : string_view rangeFrom ) {
const std : : string repo = repoPath . string ( ) ;
Base result ;
const GitRun up = Git ( repo , { " rev-parse " , " --verify " , " -q " , " @{upstream} " } ) ;
if ( up . code = = 0 ) result . upstream = Dyn : : Strip ( up . out ) ;
auto mergeBase = [ & ] ( ) - > std : : optional < std : : string > {
const GitRun run = Git ( repo , { " merge-base " , " HEAD " , * configBase } ) ;
if ( run . code ! = 0 ) {
result . failure = std : : format ( " config base {} does not resolve: {} " , Dyn : : Repr ( * configBase ) , Dyn : : Strip ( run . err ) ) ;
return std : : nullopt ;
}
return Dyn : : Strip ( run . out ) ;
} ;
const bool haveConfigBase = configBase & & ! configBase - > empty ( ) ;
std : : string chosen ;
if ( ! base . empty ( ) ) {
chosen = base ;
result . how = " --base " ;
} else if ( rangeFrom = = " base " & & haveConfigBase ) {
const std : : optional < std : : string > found = mergeBase ( ) ;
if ( ! found ) return result ;
chosen = * found ;
result . how = std : : format ( " merge-base HEAD {} (config rangeFrom base) " , * configBase ) ;
} else if ( result . upstream ) {
chosen = * result . upstream ;
result . how = " @{upstream} " ;
} else if ( haveConfigBase ) {
const std : : optional < std : : string > found = mergeBase ( ) ;
if ( ! found ) return result ;
chosen = * found ;
result . how = std : : format ( " merge-base HEAD {} (config base) " , * configBase ) ;
} else {
result . failure = " no upstream branch configured, no --base given and no base in .review-gates/config.json " ;
return result ;
}
const GitRun resolved = Git ( repo , { " rev-parse " , " --verify " , " -q " , std : : format ( " {}^{{commit}} " , chosen ) } ) ;
if ( resolved . code ! = 0 ) {
result . how . clear ( ) ;
result . failure = std : : format ( " base {} does not resolve: {} " , Dyn : : Repr ( chosen ) , Dyn : : Strip ( resolved . err ) ) ;
return result ;
}
result . sha = Dyn : : Strip ( resolved . out ) ;
return result ;
}
Json Run ( const fs : : path & repoPath , const fs : : path & outPath , const Options & options ) {
const std : : string cwd = options . cwd . empty ( ) ? fs : : current_path ( ) . string ( ) : options . cwd . string ( ) ;
const std : : string repo = Dyn : : AbsPath ( repoPath . string ( ) ) ;
const std : : string out = Dyn : : AbsPath ( outPath . string ( ) ) ;
Json report = {
{ " ok " , false } , { " repo " , repo } , { " out " , out } , { " date " , options . date . empty ( ) ? Platform : : TodayIso ( ) : options . date } ,
{ " failures " , Json : : array ( ) } , { " warnings " , Json : : array ( ) } ,
} ;
auto fail = [ & ] ( std : : string message ) { report [ " failures " ] . push_back ( std : : move ( message ) ) ; } ;
auto warn = [ & ] ( std : : string message ) { report [ " warnings " ] . push_back ( std : : move ( message ) ) ; } ;
auto failed = [ & ] { return ! report [ " failures " ] . empty ( ) ; } ;
if ( ! Artifact : : IsToplevel ( repo ) ) {
fail ( std : : format ( " {} is not the toplevel of a git checkout " , repo ) ) ;
return report ;
}
std : : vector < std : : string > authors = options . authors ;
if ( authors . empty ( ) ) authors = Config : : Authors ( { { " authors " , nullptr } } , repo ) ;
if ( authors . empty ( ) ) {
fail ( " no author identity: set `authors` in .review-gates/config.json or git config user.email " ) ;
return report ;
}
const Base base = ResolveBase ( repo , options . base , options . configBase , options . rangeFrom ) ;
if ( ! base . failure . empty ( ) ) {
fail ( base . failure ) ;
return report ;
}
const std : : string head = Artifact : : TreeHead ( repo ) ;
Json authorList = Json : : array ( ) ;
for ( const std : : string & author : authors ) authorList . push_back ( author ) ;
report [ " base " ] = base . sha ;
report [ " baseFrom " ] = base . how ;
report [ " upstream " ] = base . upstream ? Json ( * base . upstream ) : Json ( ) ;
report [ " head " ] = head ;
report [ " authors " ] = authorList ;
report [ " aiTrailers " ] = options . aiTrailers ;
if ( Git ( repo , { " merge-base " , " --is-ancestor " , base . sha , " HEAD " } ) . code ! = 0 ) {
fail ( std : : format ( " base {} is not an ancestor of HEAD {} " , Head12 ( base . sha ) , Head12 ( head ) ) ) ;
return report ;
}
if ( base . upstream ) {
const GitRun behind = Git ( repo , { " rev-list " , " --count " , std : : format ( " HEAD..{} " , * base . upstream ) } ) ;
const std : : string count = Dyn : : Strip ( behind . out ) ;
if ( behind . code = = 0 & & ( count . empty ( ) ? 0 : Dyn : : ParseInt ( count ) . value_or ( 0 ) ) ! = 0 ) {
fail ( std : : format ( " upstream has {} commit(s) HEAD lacks - the push would not be a fast-forward; fetch and rebase first " , count ) ) ;
}
if ( base . sha ! = * base . upstream & & base . how ! = " @{upstream} " ) {
warn ( std : : format ( " base {} ({}) is not the upstream tip {}: the artifact is the whole range, not only the push delta " , Head12 ( base . sha ) , base . how , Head12 ( * base . upstream ) ) ) ;
}
} else {
warn ( std : : format ( " no upstream branch: fast-forward not checked{} " , base . how . empty ( ) ? std : : string ( ) : std : : format ( " ; the base is {} " , base . how ) ) ) ;
}
if ( base . sha = = head ) fail ( " nothing to freeze: base == HEAD " ) ;
if ( failed ( ) ) return report ;
const GitRun merges = Git ( repo , { " rev-list " , " --merges " , std : : format ( " {}..HEAD " , base . sha ) } ) ;
if ( ! Dyn : : Strip ( merges . out ) . empty ( ) ) {
std : : vector < std : : string > shas ;
for ( const std : : string & sha : Dyn : : Split ( merges . out ) ) shas . push_back ( Head12 ( sha ) ) ;
fail ( std : : format ( " merge commit(s) in the range (git format-patch silently drops them): {} " , Dyn : : Join ( shas , " , " ) ) ) ;
}
const std : : vector < Commit > commits = CommitsIn ( repo , base . sha ) ;
Json commitList = Json : : array ( ) ;
for ( const Commit & c : commits ) {
commitList . push_back ( { { " sha " , c . sha } , { " subject " , c . subject } , { " author " , c . author } , { " committer " , c . committer } , { " sig " , c . sig } } ) ;
}
report [ " commits " ] = commitList ;
const std : : string authorText = Dyn : : Join ( authors , " , " ) ;
for ( const Commit & c : commits ) {
const std : : string who = std : : format ( " {} {} " , Head12 ( c . sha ) , Dyn : : Repr ( Dyn : : Slice ( c . subject , 0 , 50 ) ) ) ;
if ( ! std : : ranges : : contains ( authors , c . author ) ) fail ( std : : format ( " {}: author {} not in {} " , who , c . author , authorText ) ) ;
if ( ! std : : ranges : : contains ( authors , c . committer ) ) fail ( std : : format ( " {}: committer {} not in {} " , who , c . committer , authorText ) ) ;
if ( const std : : optional < std : : string > trailer = AiTrailer ( c . body ) ) {
const bool forbid = options . aiTrailers = = " forbid " ;
const std : : string message = std : : format ( " {}: AI attribution trailer {}{} " , who , Dyn : : Repr ( Dyn : : Strip ( * trailer ) ) ,
forbid ? " - this repository carries none (config aiTrailers: forbid) " : " (config aiTrailers: allow) " ) ;
forbid ? fail ( message ) : warn ( message ) ;
}
if ( c . sig ! = " G " & & c . sig ! = " U " ) {
const std : : string message = std : : format ( " {}: not GPG-signed (%G? = {}) " , who , c . sig ) ;
options . requireSigned ? fail ( message ) : warn ( message ) ;
}
}
const auto [ counted , allowed ] = DirtyPaths ( repo , options . allowDirty ) ;
if ( ! counted . empty ( ) ) {
std : : vector < std : : string > shown ;
for ( std : : size_t i = 0 ; i < counted . size ( ) & & i < 5 ; + + i ) shown . push_back ( Dyn : : Slice ( counted [ i ] , 3 ) ) ;
fail ( std : : format ( " uncommitted tracked changes in the worktree - commit or stash them; the lenses read the checkout: {} " , Dyn : : Join ( shown , " , " ) ) ) ;
}
if ( ! allowed . empty ( ) ) {
std : : vector < std : : string > shown ;
for ( const std : : string & line : allowed ) shown . push_back ( Dyn : : Slice ( line , 3 ) ) ;
warn ( std : : format ( " tracked changes the config allows (allowDirty - the local stack rewrites them): {} " , Dyn : : Join ( shown , " , " ) ) ) ;
}
const GitRun all = Git ( repo , { " status " , " --porcelain " , " --untracked-files=all " } ) ;
std : : vector < std : : string > untracked ;
for ( const std : : string & line : Dyn : : SplitLines ( all . out ) ) {
if ( line . starts_with ( " ?? " ) ) untracked . push_back ( Dyn : : Slice ( line , 3 ) ) ;
}
if ( ! untracked . empty ( ) ) {
const std : : vector < std : : string > shown ( untracked . begin ( ) , untracked . begin ( ) + static_cast < std : : ptrdiff_t > ( std : : min < std : : size_t > ( untracked . size ( ) , 5 ) ) ) ;
warn ( std : : format ( " {} untracked file(s), never part of a commit: {}{} " , untracked . size ( ) , Dyn : : Join ( shown , " , " ) , untracked . size ( ) > 5 ? " ... " : " " ) ) ;
}
if ( failed ( ) ) return report ;
fs : : create_directories ( out ) ;
const std : : vector < std : : string > existing = Artifact : : PatchFiles ( out ) ;
if ( ! existing . empty ( ) ) {
if ( ! options . replace ) {
fail ( std : : format ( " {} already holds {} patch file(s) - pass --replace to re-freeze (gate-state/ is kept) " , out , existing . size ( ) ) ) ;
return report ;
}
for ( const std : : string & name : existing ) fs : : remove ( fs : : path ( out ) / name ) ;
}
const GitRun exported = Git ( repo , { " format-patch " , std : : format ( " --base={} " , base . sha ) , " -o " , out , std : : format ( " {}..HEAD " , base . sha ) } ) ;
if ( exported . code ! = 0 ) {
fail ( std : : format ( " format-patch failed: {} " , Dyn : : Strip ( exported . err ) ) ) ;
return report ;
}
const std : : vector < std : : string > files = Artifact : : PatchFiles ( out ) ;
if ( files . size ( ) ! = commits . size ( ) ) {
fail ( std : : format ( " exported {} patch file(s) for {} commit(s) " , files . size ( ) , commits . size ( ) ) ) ;
return report ;
}
Json fileList = Json : : array ( ) ;
for ( const std : : string & name : files ) fileList . push_back ( name ) ;
report [ " files " ] = fileList ;
report [ " hash " ] = Artifact : : ArtifactHash ( " commits " , out ) ;
if ( Artifact : : PatchHead ( out ) ! = head ) {
fail ( " the last patch does not name HEAD " ) ;
return report ;
}
const fs : : path gateState = fs : : path ( out ) / " gate-state " ;
fs : : create_directories ( gateState ) ;
if ( ! fs : : exists ( gateState / " dispositions.json " ) ) std : : ofstream ( gateState / " dispositions.json " , std : : ios : : binary ) < < " [] " ;
if ( options . worktree ) {
const std : : string name = Dyn : : BaseName ( Dyn : : RStrip ( repo , " / " ) ) ;
const std : : string hash = report [ " hash " ] . get < std : : string > ( ) ;
const fs : : path worktrees = fs : : path ( cwd ) / Worktrees ;
const std : : string wt = ( worktrees / std : : format ( " {}-{} " , name , hash . substr ( 0 , 8 ) ) ) . string ( ) ;
// glob.glob(<worktrees>/<name>-*), as glob reads it: readdir order,
// and no hidden entry unless the pattern starts with a dot.
std : : vector < std : : string > stale ;
std : : error_code error ;
if ( fs : : is_directory ( worktrees , error ) ) {
for ( const fs : : directory_entry & entry : fs : : directory_iterator ( worktrees , error ) ) {
const std : : string entryName = entry . path ( ) . filename ( ) . string ( ) ;
if ( entryName . starts_with ( ' . ' ) & & ! name . starts_with ( ' . ' ) ) continue ;
if ( Dyn : : FnMatch ( entryName , std : : format ( " {}-* " , name ) ) & & entry . path ( ) . string ( ) ! = wt ) stale . push_back ( entry . path ( ) . string ( ) ) ;
}
}
const bool present = fs : : exists ( wt ) ;
if ( present | | ! stale . empty ( ) ) {
std : : vector < std : : string > superseded ;
if ( present ) superseded . push_back ( wt ) ;
superseded . insert ( superseded . end ( ) , stale . begin ( ) , stale . end ( ) ) ;
if ( ! options . replace ) {
std : : vector < std : : string > shown ;
for ( const std : : string & d : superseded ) shown . push_back ( Dyn : : RelPath ( d , cwd ) ) ;
fail ( std : : format ( " worktree(s) of this repo exist - pass --replace to supersede them: {} " , Dyn : : Join ( shown , " , " ) ) ) ;
return report ;
}
for ( const std : : string & d : superseded ) Git ( repo , { " worktree " , " remove " , " --force " , d } ) ;
}
fs : : create_directories ( fs : : path ( wt ) . parent_path ( ) ) ;
const GitRun added = Git ( repo , { " worktree " , " add " , " --detach " , wt , " HEAD " } ) ;
if ( added . code ! = 0 ) {
fail ( std : : format ( " worktree add failed: {} " , Dyn : : Strip ( added . err ) ) ) ;
return report ;
}
report [ " worktree " ] = Dyn : : RelPath ( wt , cwd ) ;
}
report [ " ok " ] = true ;
Json frozen = Json : : object ( ) ;
for ( auto it = report . begin ( ) ; it ! = report . end ( ) ; + + it ) {
if ( it . key ( ) ! = " ok " & & it . key ( ) ! = " failures " ) frozen [ it . key ( ) ] = it . value ( ) ;
}
frozen [ " gitVersion " ] = GitVersion ( ) ;
std : : ofstream ( fs : : path ( out ) / " freeze.json " , std : : ios : : binary ) < < DumpFile ( frozen ) ;
return report ;
}
std : : vector < std : : string > Verify ( const fs : : path & repoPath , const fs : : path & outPath , std : : span < const std : : string > allowDirty ) {
const std : : string repo = Dyn : : AbsPath ( repoPath . string ( ) ) ;
const std : : string out = Dyn : : AbsPath ( outPath . string ( ) ) ;
const fs : : path file = fs : : path ( out ) / " freeze.json " ;
if ( ! fs : : exists ( file ) ) return { std : : format ( " {} missing - not a frozen artifact " , file . string ( ) ) } ;
const Json frozen = Dyn : : LoadJsonFile ( file ) ;
const std : : string frozenHash = Dyn : : At ( frozen , " hash " ) . get < std : : string > ( ) ;
const std : : string frozenBase = Dyn : : At ( frozen , " base " ) . get < std : : string > ( ) ;
std : : vector < std : : string > problems ;
const std : : string head = Artifact : : TreeHead ( repo ) ;
const std : : string frozenHead = Dyn : : At ( frozen , " head " ) . get < std : : string > ( ) ;
if ( head ! = frozenHead ) problems . push_back ( std : : format ( " HEAD {} != frozen head {} " , Head12 ( head ) , Head12 ( frozenHead ) ) ) ;
const auto [ counted , allowed ] = DirtyPaths ( repo , allowDirty ) ;
if ( ! counted . empty ( ) ) {
std : : vector < std : : string > shown ;
for ( std : : size_t i = 0 ; i < counted . size ( ) & & i < 5 ; + + i ) shown . push_back ( Dyn : : Slice ( counted [ i ] , 3 ) ) ;
problems . push_back ( std : : format ( " uncommitted tracked changes: {} " , Dyn : : Join ( shown , " , " ) ) ) ;
}
const std : : string current = Artifact : : ArtifactHash ( " commits " , out ) ;
if ( current ! = frozenHash ) problems . push_back ( std : : format ( " artifact dir hashes {}, freeze.json says {} (edited after the freeze?) " , Head12 ( current ) , Head12 ( frozenHash ) ) ) ;
const TemporaryDirectory temporary ;
const GitRun exported = Git ( repo , { " format-patch " , std : : format ( " --base={} " , frozenBase ) , " -o " , temporary . path . string ( ) , std : : format ( " {}..HEAD " , frozenBase ) } ) ;
if ( exported . code ! = 0 ) {
problems . push_back ( std : : format ( " re-export failed: {} " , Dyn : : Strip ( exported . err ) ) ) ;
} else {
const std : : string fresh = Artifact : : ArtifactHash ( " commits " , temporary . path ) ;
if ( fresh ! = frozenHash ) {
std : : string problem = std : : format ( " a fresh export hashes {} != frozen {} " , Head12 ( fresh ) , Head12 ( frozenHash ) ) ;
// format-patch signs every patch with its version: the likely
// cause when nothing else changed. Where freeze.json has no
// gitVersion, the patches still name it.
const Json & recorded = Dyn : : Get ( frozen , " gitVersion " ) ;
const std : : string then = recorded . is_string ( ) & & ! recorded . get_ref < const std : : string & > ( ) . empty ( ) ? recorded . get < std : : string > ( ) : PatchGitVersion ( out ) ;
const std : : string now = PatchGitVersion ( temporary . path ) ;
if ( ! then . empty ( ) & & ! now . empty ( ) & & then ! = now ) {
problem + = std : : format ( " - git {} exported the artifact and git {} the fresh copy; format-patch writes its version into every patch, so re-freeze with this git " , then , now ) ;
}
problems . push_back ( std : : move ( problem ) ) ;
}
}
return problems ;
}
std : : string RunLine ( const Json & report , std : : string_view outRel , std : : string_view trees ) {
return std : : format ( " {} run --artifact {} --hash {} --base {} --trees {} --dispositions {} --date {} " , Invocation ( ) , outRel , Dyn : : Str ( Dyn : : At ( report , " hash " ) ) ,
Dyn : : Str ( Dyn : : At ( report , " base " ) ) , trees , Dyn : : JoinPath ( Dyn : : JoinPath ( outRel , " gate-state " ) , " dispositions.json " ) , Dyn : : Str ( Dyn : : At ( report , " date " ) ) ) ;
}
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
std : : string TreeOf ( const Json & record , const fs : : path & cwd ) {
const Json & worktree = Dyn : : Get ( record , " worktree " ) ;
if ( Dyn : : Truthy ( worktree ) ) return Dyn : : Str ( worktree ) ;
const Json & repo = Dyn : : Get ( record , " repo " ) ;
if ( ! Dyn : : Truthy ( repo ) ) return " . " ;
std : : error_code error ;
return fs : : weakly_canonical ( fs : : path ( Dyn : : Str ( repo ) ) , error ) = = fs : : weakly_canonical ( cwd , error ) ? std : : string ( " . " ) : Dyn : : Str ( repo ) ;
}
Reviewed ReviewedBy ( const fs : : path & artifact , const fs : : path & cwd ) {
const fs : : path file = artifact / " freeze.json " ;
std : : error_code error ;
if ( ! fs : : exists ( file , error ) ) return { cwd , " . " } ;
Json record ;
try {
record = Dyn : : LoadJsonFile ( file ) ;
} catch ( const std : : exception & e ) {
throw std : : runtime_error ( std : : format ( " {}: not a freeze record ({}) " , file . string ( ) , e . what ( ) ) ) ;
}
if ( ! record . is_object ( ) ) throw std : : runtime_error ( std : : format ( " {}: not a freeze record " , file . string ( ) ) ) ;
const Json & repo = Dyn : : Get ( record , " repo " ) ;
return { Dyn : : Truthy ( repo ) ? fs : : path ( Dyn : : AbsPath ( Dyn : : Str ( repo ) ) ) : cwd , TreeOf ( record , cwd ) } ;
}
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
std : : int32_t Main ( const Arguments & arguments , const Json & config ) {
const std : : string cwd = fs : : current_path ( ) . string ( ) ;
const std : : string repo = Dyn : : AbsPath ( arguments . repo & & ! arguments . repo - > empty ( ) ? * arguments . repo : cwd ) ;
std : : optional < std : : string > out ;
if ( arguments . out & & ! arguments . out - > empty ( ) ) {
out = * arguments . out ;
} else if ( arguments . slug & & ! arguments . slug - > empty ( ) ) {
out = Dyn : : JoinPath ( PushDir , * arguments . slug ) ;
}
if ( ! out ) {
std : : println ( " FAIL: --slug <name> (the artifact dir .review-gates/push/<name>/) or --out <dir> " ) ;
return 3 ;
}
const std : : vector < std : : string > allowDirty = Strings ( Dyn : : Get ( config , " allowDirty " ) ) ;
if ( arguments . verify ) {
const std : : vector < std : : string > problems = Verify ( repo , * out , allowDirty ) ;
const std : : string detail = problems . empty ( ) ? std : : format ( " {} is exactly what {} HEAD exports " , Dyn : : AbsPath ( * out ) , repo ) : Dyn : : Join ( problems , " ; " ) ;
std : : println ( " {}: {} " , problems . empty ( ) ? " MATCH " : " MISMATCH " , detail ) ;
return problems . empty ( ) ? 0 : 1 ;
}
Options options ;
options . base = arguments . base . value_or ( " " ) ;
options . replace = arguments . replace ;
options . worktree = arguments . worktree ;
options . requireSigned = arguments . requireSigned | | Dyn : : Truthy ( Dyn : : Get ( config , " requireSigned " ) ) ;
options . authors = Config : : Authors ( config , repo ) ;
options . aiTrailers = Dyn : : Str ( Dyn : : Get ( config , " aiTrailers " ) ) ;
options . allowDirty = allowDirty ;
const Json & configBase = Dyn : : Get ( config , " base " ) ;
if ( configBase . is_string ( ) ) options . configBase = configBase . get < std : : string > ( ) ;
options . rangeFrom = Dyn : : Str ( Dyn : : Get ( config , " rangeFrom " ) ) ;
options . date = arguments . date ;
const Json report = Run ( repo , * out , options ) ;
for ( const Json & warning : report . at ( " warnings " ) ) std : : println ( " warning: {} " , warning . get < std : : string > ( ) ) ;
if ( ! report . at ( " ok " ) . get < bool > ( ) ) {
for ( const Json & failure : report . at ( " failures " ) ) std : : println ( " FAIL: {} " , failure . get < std : : string > ( ) ) ;
return 3 ;
}
const std : : string rel = Dyn : : RelPath ( report . at ( " out " ) . get < std : : string > ( ) ) ;
std : : vector < std : : string > files ;
for ( const Json & name : report . at ( " files " ) ) files . push_back ( name . get < std : : string > ( ) ) ;
std : : println ( " frozen: {} commit(s) {}..{} (base from {}) -> {}/ ({}) " , files . size ( ) , Head12 ( report . at ( " base " ) . get < std : : string > ( ) ) , Head12 ( report . at ( " head " ) . get < std : : string > ( ) ) ,
report . at ( " baseFrom " ) . get < std : : string > ( ) , rel , Dyn : : Join ( files , " , " ) ) ;
std : : println ( " hash: {} " , report . at ( " hash " ) . get < std : : string > ( ) ) ;
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
const std : : string line = RunLine ( report , rel , TreeOf ( report , cwd ) ) ;
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
std : : ofstream ( fs : : path ( report . at ( " out " ) . get < std : : string > ( ) ) / " run-line.txt " , std : : ios : : binary ) < < line < < " \n " ;
std : : println ( " run (from the repository root, in a detached background shell; also in {}): \n {} " , Dyn : : JoinPath ( rel , " run-line.txt " ) , line ) ;
return 0 ;
}
}