You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

2783 lines
159 KiB

# -*- tcl -*-
# Maintenance Instruction: leave the 999999.xxx.x as is and use 'dev make' or src/make.tcl to update from <pkg>-buildversion.txt
#
# Please consider using a BSD or MIT style license for greatest compatibility with the Tcl ecosystem.
# Code using preferred Tcl licenses can be eligible for inclusion in Tcllib, Tklib and the punk package repository.
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# (C) 2023
#
# @@ Meta Begin
# Application punkcheck 0.1.0
# Meta platform tcl
# Meta license <unspecified>
# @@ Meta End
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Requirements
##e.g package require frobz
package require punk::tdl
package require punk::path
package require punk::repo
package require punk::mix::util
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# Punkcheck uses the TDL format which is a list of lists in Tcl format
# It is intended primarily for source build/distribution tracking within a punk project or single filesystem - with relative paths.
#
#see following article regarding the many problems with using mtime for build-decisions: https://apenwarr.ca/log/20181113
#
namespace eval punkcheck {
namespace export {*}{
uuid
installtrack
install
install_tm_files
install_non_tm_files
summarize_install_resultdict
}
#exclude-paths entries match against the full relative path using globmatchpath.
#They may include ** to match across path segments and / to match within a segment.
#Each dir-seg pattern is expanded to both top-level and nested (**/) forms to preserve
#the "matches at any directory level" semantics of the former dir-seg mechanism.
variable default_excludepaths_core [list "#*" "**/#*" "_aside" "**/_aside" "_build" "**/_build" ".git" "**/.git" ".fossil*" "**/.fossil*"]
variable default_excludefiletail_core ""
#backward-compat: retained for callers that still read default_excludedirseg_core
variable default_excludedirseg_core [list "#*" "_aside" "_build" ".git" ".fossil*"]
set has_twapi 0
if {"windows" eq $::tcl_platform(platform)} {
set has_twapi [expr {![catch {package require twapi}]}]
}
if {$has_twapi} {
interp alias "" ::punkcheck::uuid "" ::twapi::new_uuid
} else {
catch {package require uuid}
interp alias "" ::punkcheck::uuid "" ::uuid::uuid generate
}
proc default_excludepaths_core {} {
variable default_excludepaths_core
return $default_excludepaths_core
}
#backward-compat alias for the former segment-based default
proc default_excludedirseg_core {} {
variable default_excludedirseg_core
return $default_excludedirseg_core
}
proc default_excludefiletail_core {} {
variable default_excludefiletail_core
if {$default_excludefiletail_core eq ""} {
#.punkcheck.* covers the G-095 protocol siblings (.punkcheck.lock, .punkcheck.tmp.<pid>)
set default_excludefiletail_core [list "*.swp" "*[punk::mix::util::tm_version_magic]*" "*-buildversion.txt" ".punkcheck" ".punkcheck.*"]
}
return $default_excludefiletail_core
}
proc load_records_from_file {punkcheck_file} {
set record_list [list]
if {[file exists $punkcheck_file]} {
#G-095: the atomic-rename save keeps content untorn, but on Windows a reader's open can
#transiently be denied while a writer's MoveFileEx replace is in flight - retry the read
#with backoff. Parse errors stay strict (they indicate real corruption, never a race).
set backoff 10
set deadline [expr {[clock milliseconds] + 2000}]
while {1} {
if {![catch {punk::mix::util::fcat $punkcheck_file} tdlscript]} {
break
}
if {![file exists $punkcheck_file]} {
#genuinely deleted between our checks - same as never present
return $record_list
}
if {[clock milliseconds] >= $deadline} {
error "punkcheck::load_records_from_file failed to read '$punkcheck_file'\n error:$tdlscript"
}
after $backoff
set backoff [expr {min($backoff * 2,100)}]
}
if {[catch {
set record_list [punk::tdl::prettyparse $tdlscript]
} errparse]} {
error "punkcheck::load_records_from_file failed to parse '$punkcheck_file'\n error:$errparse"
}
}
return $record_list
}
proc save_records_to_file {recordlist punkcheck_file {trigger {}} {debugchannel ""}} {
set newtdl [punk::tdl::prettyprint $recordlist]
set linecount [llength [split $newtdl \n]]
if {$debugchannel ne "" && $trigger ne ""} {
puts $debugchannel "\x1b\[36mSaving [llength $recordlist] records as $linecount lines to file '$punkcheck_file' trigger: \x1b\[32m$trigger\x1b\[m"
}
#G-095: write-temp-then-atomic-rename so readers never observe torn content.
#The temp file lives in the same directory (same volume) as the target; the rename is retried
#with backoff for Windows sharing violations (a concurrent reader holding the target open).
set tmpfile "${punkcheck_file}.tmp.[pid]"
set fd [open $tmpfile w]
chan configure $fd -translation binary
puts -nonewline $fd $newtdl
flush $fd
close $fd
set renamed 0
set backoff 20
set deadline [expr {[clock milliseconds] + 2000}]
while {1} {
if {![catch {file rename -force -- $tmpfile $punkcheck_file} errRename]} {
set renamed 1
break
}
if {[clock milliseconds] >= $deadline} {
break
}
after $backoff
set backoff [expr {min($backoff * 2,250)}]
}
if {!$renamed} {
catch {file delete -- $tmpfile}
error "punkcheck::save_records_to_file failed to atomically replace '$punkcheck_file' (trigger: $trigger) - last rename error: $errRename"
}
#hygiene: clear abandoned temp files from crashed/killed writers (age-gated; own pattern only)
foreach leftover [glob -nocomplain -types f -- "${punkcheck_file}.tmp.*"] {
if {![catch {file mtime $leftover} lmt] && ([clock seconds] - $lmt) > 600} {
catch {file delete -- $leftover}
}
}
return [list recordcount [llength $recordlist] linecount $linecount]
}
#-----------------------------------------------
#G-095 advisory lockfile protocol: serializes whole installer events per punkcheck root.
#Cross-platform primitive: open {WRONLY CREAT EXCL} is an atomic create-new on Windows and
#POSIX under Tcl 8.6 and 9 with no extra packages. The holder keeps the channel open for the
#lock's duration (on Windows an open handle also resists deletion by other processes).
#In-process re-acquisition is reference-counted: sequential installtrack instances over one
#.punkcheck file in one process are an established make.tcl pattern and must not self-deadlock.
#The lock is advisory - readers (punkcheck::cli status, load_records_from_file) never take it;
#their torn-read immunity comes from the atomic rename in save_records_to_file.
namespace eval lock {
namespace path ::punkcheck
variable held
if {![info exists held]} {
set held [dict create] ;#normalized lockfile path -> dict {channel <chan> refcount <n>}
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkcheck::lock::acquire
@cmd -name "punkcheck::lock::acquire"\
-summary\
"Acquire the advisory event lock for a punkcheck file."\
-help\
"Acquires the advisory sibling lockfile <punkcheck_file>.lock via an atomic
create-new open, serializing whole installer events per punkcheck root
across processes. The holder identity (pid/host/installer/timestamp) is
written into the lockfile and the channel is held open for the lock's
duration. In-process re-acquisition is reference-counted.
On contention the acquisition retries with backoff until -timeout, breaking
stale locks early (provably-dead holder pid on this host when a liveness
capability is present, or lockfile age beyond -staleage), then errors with
errorcode {PUNKCHECK LOCK TIMEOUT <lockfile>} naming the holder.
Returns a token for punkcheck::lock::release.
The lock is advisory: readers never take it (torn-read immunity comes from
the atomic-rename save)."
@leaders -min 1 -max 1
punkcheck_file -type file -help\
"Path of the .punkcheck file whose sibling lockfile governs the root."
@opts
-timeout -type integer -help\
"Milliseconds to keep retrying on contention before erroring.
Default 15000, or env(PUNKCHECK_LOCK_TIMEOUT) when set."
-staleage -type integer -help\
"Seconds beyond which a contended lockfile is broken on age alone.
Default 300, or env(PUNKCHECK_LOCK_STALEAGE) when set."
-installer -type string -default "" -help\
"Holder identity recorded in the lockfile contents."
-warnchannel -type string -default stderr -help\
"Channel receiving stale-break warnings."
}]
lappend PUNKARGS [list {
@id -id ::punkcheck::lock::release
@cmd -name "punkcheck::lock::release"\
-summary\
"Release a token from punkcheck::lock::acquire."\
-help\
"Reference-counted: the lockfile channel is closed and the file deleted when
the in-process count reaches zero. Unknown tokens return 0 (idempotent for
cleanup paths); handled tokens return 1."
@values -min 1 -max 1
token -type string -help\
"Token returned by punkcheck::lock::acquire."
}]
}
#acquire the advisory event lock for a punkcheck file. Returns a token for release.
# -timeout ms to keep retrying on contention before erroring (names the holder)
# -staleage seconds after which a contended lockfile is broken on age alone
# -installer holder identity recorded in the lockfile contents
#env overrides PUNKCHECK_LOCK_TIMEOUT / PUNKCHECK_LOCK_STALEAGE apply when options are not passed.
proc acquire {punkcheck_file args} {
variable held
set default_timeout 15000
set default_staleage 300
if {[info exists ::env(PUNKCHECK_LOCK_TIMEOUT)] && [string is integer -strict $::env(PUNKCHECK_LOCK_TIMEOUT)]} {
set default_timeout $::env(PUNKCHECK_LOCK_TIMEOUT)
}
if {[info exists ::env(PUNKCHECK_LOCK_STALEAGE)] && [string is integer -strict $::env(PUNKCHECK_LOCK_STALEAGE)]} {
set default_staleage $::env(PUNKCHECK_LOCK_STALEAGE)
}
set defaults [dict create -timeout $default_timeout -staleage $default_staleage -installer "" -warnchannel stderr]
if {[llength $args] % 2} {
error "punkcheck::lock::acquire arguments after punkcheck_file must be -flag value pairs. known flags: [dict keys $defaults]"
}
foreach {k v} $args {
if {$k ni [dict keys $defaults]} {
error "punkcheck::lock::acquire unrecognised flag '$k'. known flags: [dict keys $defaults]"
}
}
set opts [dict merge $defaults $args]
set timeout [dict get $opts -timeout]
set staleage [dict get $opts -staleage]
set installer [dict get $opts -installer]
set warnchannel [dict get $opts -warnchannel]
set lockfile [file normalize "${punkcheck_file}.lock"]
if {[dict exists $held $lockfile]} {
#in-process re-acquisition (sequential or nested installers on one punkcheck root)
dict set held $lockfile refcount [expr {[dict get $held $lockfile refcount] + 1}]
return $lockfile
}
set deadline [expr {[clock milliseconds] + $timeout}]
set backoff 50
while {1} {
if {![catch {open $lockfile {WRONLY CREAT EXCL}} fd]} {
chan configure $fd -translation lf
set holderinfo [dict create pid [pid] host [info hostname] installer $installer tsiso [clock format [clock seconds] -format "%Y-%m-%dT%H:%M:%S"] ts [clock microseconds]]
puts $fd $holderinfo
flush $fd
dict set held $lockfile [dict create channel $fd refcount 1]
return $lockfile
}
#contended - identify the holder (best effort: the file can vanish between the
#failed EXCL open and this read)
set holderinfo ""
catch {
set rfd [open $lockfile r]
set holderinfo [string trim [read $rfd]]
close $rfd
}
set holder_pid ""
set holder_host ""
if {[llength $holderinfo] % 2 == 0} {
set holder_pid [dict_getwithdefault $holderinfo pid ""]
set holder_host [dict_getwithdefault $holderinfo host ""]
}
set broke 0
#liveness stale-break: holder recorded on this host and provably dead (capability-gated)
if {[string is integer -strict $holder_pid] && $holder_host eq [info hostname] && $holder_pid != [pid]} {
if {[pid_liveness $holder_pid] eq "dead"} {
catch {puts $warnchannel "punkcheck::lock warning: breaking stale lock '$lockfile' - holder pid $holder_pid on this host is no longer running (holder: $holderinfo)"}
catch {file delete -- $lockfile}
set broke 1
}
}
if {!$broke} {
#age-based stale-break: the portable baseline
if {[catch {file mtime $lockfile} lockmtime]} {
#lockfile vanished since the failed open - retry immediately
set broke 1
} elseif {([clock seconds] - $lockmtime) > $staleage} {
catch {puts $warnchannel "punkcheck::lock warning: breaking stale lock '$lockfile' - older than ${staleage}s (holder: $holderinfo)"}
catch {file delete -- $lockfile}
set broke 1
}
}
if {!$broke} {
if {[clock milliseconds] >= $deadline} {
if {$holderinfo eq ""} {
set holderdesc "(holder unknown - lockfile unreadable)"
} else {
set holderdesc $holderinfo
}
return -code error -errorcode [list PUNKCHECK LOCK TIMEOUT $lockfile] "punkcheck::lock::acquire timeout after ${timeout}ms waiting for '$lockfile' held by: $holderdesc"
}
after $backoff
set backoff [expr {min($backoff * 2,500)}]
}
}
}
#release a token from acquire. Reference-counted; the lockfile is closed and deleted when
#the count reaches zero. Unknown tokens return 0 (idempotent for cleanup paths).
proc release {token} {
variable held
if {![dict exists $held $token]} {
return 0
}
set refcount [expr {[dict get $held $token refcount] - 1}]
if {$refcount > 0} {
dict set held $token refcount $refcount
return 1
}
catch {close [dict get $held $token channel]}
catch {file delete -- $token}
dict unset held $token
return 1
}
#alive | dead | unknown for a pid on this host (capability-gated liveness)
proc pid_liveness {pid} {
if {"windows" eq $::tcl_platform(platform)} {
if {![catch {package require twapi}]} {
if {[catch {twapi::process_exists $pid} exists]} {
return unknown
}
return [expr {$exists ? "alive" : "dead"}]
}
return unknown
} else {
if {![catch {exec kill -0 $pid}]} {
return alive
} else {
#kill -0 errors for both nonexistent (No such process) and not-permitted (alive,
#other user) - only the former is a confident 'dead'
catch {exec kill -0 $pid} errkill
if {[string match -nocase "*no such process*" $errkill]} {
return dead
}
return unknown
}
}
}
}
#Concurrent multi-process writer safety (G-095) lives at the single save chokepoint (installtrack
#method save_working_recordset) and its callers: saves are write-temp-then-atomic-rename, whole
#installer events hold the advisory .punkcheck.lock (punkcheck::lock, start_event through end),
#deferred flushes merge own records into freshly-loaded file state, and an optimistic
#mtime/size tripwire warns when a non-protocol writer touched the file between load and save.
#an installtrack object represents an installation path from sourceroot to targetroot
#The source and target folders should be as specific as possible but it is valid to specify for example c:/ -> c:/ (or / -> /) if source and targets within the installation operation are spread around.
#
#Record lifecycle (G-094): the installtrack/installevent OO layer is the sole implementation of the
#.punkcheck record lifecycle (targetset init / add-source / started / end). Each event carries a
#persistence policy chosen at start_event:
# eager (default) - every non-QUERY targetset operation is an independent read-modify-write of the file
# deferred - targetset operations update the installtrack's in-memory working recordset only;
# the recordset is persisted by an explicit $installer flush (event-scoped save).
# An ended deferred event that was never flushed deliberately discards its updates
# (this is how -source-checksum compare mode avoids storing).
#QUERY operations are never persisted in either mode and never touch the working recordset.
set objname [namespace current]::installtrack
if {$objname ni [info commands $objname]} {
package require oolib
#FILEINFO record - target fileset with body records: INSTALL-RECORD,INSTALL-INPROGRESS,INSTALL-SKIPPED,DELETE-RECORD,DELETE-INPROGRESS,MODIFY-INPROGRESS,MODIFY-RECORD
#each FILEINFO body being a list of SOURCE records
#instances created by an installtrack object in method start_event
#also in installtrack constructor - to represent existing events from the .punkcheck data
oo::class create installevent {
variable o_id
variable o_rel_sourceroot
variable o_rel_targetroot
variable o_ts_begin
variable o_ts_end
variable o_types
variable o_configdict
variable o_targets
variable o_operation
variable o_operation_start_ts
variable o_path_cksum_cache
variable o_fileset_record
variable o_installer ;#parent object
variable o_debugchannel
variable o_persistence
constructor {installer rel_sourceroot rel_targetroot args} {
set o_installer $installer
set o_debugchannel [$installer get_debugchannel]
set o_operation_start_ts ""
set o_path_cksum_cache [dict create]
set o_operation ""
set defaults [dict create {*}{
-id ""
-tsbegin ""
-config {}
-tsend ""
-types {}
-persistence eager
}]
set opts [dict merge $defaults $args]
set o_persistence [dict get $opts -persistence]
if {$o_persistence ni [list eager deferred]} {
error "[self] constructor error: -persistence must be 'eager' or 'deferred'. Received '$o_persistence'"
}
if {[dict get $opts -id] eq ""} {
set o_id [punkcheck::uuid]
} else {
set o_id [dict get $opts -id]
}
if {[dict get $opts -tsbegin] eq ""} {
set o_ts_begin [clock microseconds]
} else {
set o_ts_begin [dict get $opts -tsbegin]
}
set o_ts_end [dict get $opts -tsend]
set o_types [dict get $opts -types]
set o_configdict [dict get $opts -config]
set o_rel_sourceroot $rel_sourceroot
set o_rel_targetroot $rel_targetroot
}
destructor {
#puts "[self] destructor called"
}
method as_record {} {
set begin_seconds [expr {$o_ts_begin / 1000000}]
set tsiso_begin [clock format $begin_seconds -format "%Y-%m-%dT%H:%M:%S"]
if {$o_ts_end ne ""} {
set end_seconds [expr {$o_ts_end / 1000000}]
set tsiso_end [clock format $end_seconds -format "%Y-%m-%dT%H:%M:%S"]
} else {
set tsiso_end ""
}
dict create {*}{
} tag EVENT {*}{
} -tsiso_begin $tsiso_begin {*}{
} -ts_begin $o_ts_begin {*}{
} -tsiso_end $tsiso_end {*}{
} -ts_end $o_ts_end {*}{
} -id $o_id {*}{
} -source $o_rel_sourceroot {*}{
} -targets $o_rel_targetroot {*}{
} -types $o_types {*}{
} -config $o_configdict {*}{
}
}
method get_id {} {
return $o_id
}
method get_operation {} {
return $o_operation
}
method get_persistence {} {
return $o_persistence
}
method get_installer {} {
return $o_installer
}
method get_targets {} {
return $o_targets
}
method get_targets_exist {} {
set punkcheck_folder [file dirname [$o_installer get_checkfile]]
#puts stdout "### punkcheck glob -dir $punkcheck_folder -tails {*}$o_targets"
#targets can be paths such as punk/mix/commandset/module-0.1.0.tm - glob can search levels below supplied -dir
set existing [glob -nocomplain -dir $punkcheck_folder -tails {*}$o_targets]
return $existing
}
method end {} {
set o_ts_end [clock microseconds]
#notify parent so its active-event slot clears and a subsequent start_event is valid
$o_installer event_ended [self]
}
method targetset_dict {} {
punkcheck::recordlist::records_as_target_dict [$o_installer get_recordlist]
}
#merge the event's current fileset record into the parent installtrack's working recordset
#(replace the record with matching -targets, or append when absent). The parent also
#records the targetlist as touched - the 'own records' set its merge-on-flush carries
#into freshly-loaded file state.
method MergeFilesetToWorking {} {
set record_list [$o_installer get_recordlist]
set targetlist [dict get $o_fileset_record -targets]
set oldrecordinfo [punkcheck::recordlist::get_file_record $targetlist $record_list]
set old_posn [dict get $oldrecordinfo position]
if {$old_posn == -1} {
lappend record_list $o_fileset_record
} else {
lset record_list $old_posn $o_fileset_record
}
$o_installer set_recordlist $record_list
$o_installer touched_target $targetlist
}
#call init before we know if we are going to run the operation vs skip
method targetset_init {operation targetset} {
set known_ops [list QUERY INSTALL MODIFY DELETE VIRTUAL]
if {[string toupper $operation] ni $known_ops} {
error "[self] add_target unknown operation '$operation'. Known operations $known_ops"
}
set o_operation [string toupper $operation]
if {$o_operation_start_ts ne ""} {
error "[self] targetset_init $o_operation operation already in progress. Use targetset_finished or targetset_complete to finish."
}
set o_operation_start_ts [clock microseconds]
set seconds [expr {$o_operation_start_ts / 1000000}]
set tsiso [clock format $seconds -format "%Y-%m-%dT%H:%M:%S"]
set punkcheck_file [$o_installer get_checkfile]
set punkcheck_folder [file dirname $punkcheck_file]
set relativepath_targetset [list]
if {$o_operation eq "VIRTUAL"} {
foreach p $targetset {
lappend relativepath_targetset $p
}
} else {
foreach p $targetset {
if {[file pathtype $p] eq "absolute"} {
lappend relativepath_targetset [punkcheck::lib::path_relative $punkcheck_folder $p]
} else {
lappend relativepath_targetset $p
}
}
}
set fields [list {*}{
} -tsiso $tsiso {*}{
} -ts $o_operation_start_ts {*}{
} -installer [$o_installer get_name] {*}{
} -eventid $o_id {*}{
}
]
set o_targets [lsort -dictionary -increasing $relativepath_targetset] ;#exact sort order not critical - but must be consistent
#working recordset acquisition per persistence policy:
# eager - refresh the parent installtrack's working recordset from the file (each op is an independent read-modify-write)
# deferred - the parent's in-memory working recordset is authoritative between flushes; no file access
# QUERY - reads the freshest available state but never touches the working recordset (never persisted)
if {$o_operation eq "QUERY"} {
if {$o_persistence eq "deferred"} {
set record_list [$o_installer get_recordlist]
} else {
set record_list [punkcheck::load_records_from_file $punkcheck_file]
}
} else {
if {$o_persistence eq "eager"} {
$o_installer load_all_records
}
set record_list [$o_installer get_recordlist]
}
if {$o_persistence eq "eager"} {
#---------------------------------------------------------------------------
#load as dict to test for dupes (skipped in deferred mode - per-op scan cost, and the
#pre-G-094 batch recordset handling this mode inherits from never ran it either)
if {[catch {
set _targetdict [punkcheck::recordlist::records_as_target_dict $record_list]
} errMsg]} {
error "targetset_init operation:$operation error verifying existing records from file $punkcheck_file. Error: $errMsg"
}
#---------------------------------------------------------------------------
}
set extractioninfo [punkcheck::recordlist::extract_or_create_fileset_record $o_targets $record_list]
set o_fileset_record [dict get $extractioninfo record]
set record_list [dict get $extractioninfo recordset] ;#if fileset wasn't present, same as original record_list, otherwise full recordset with the fileset record removed, ready for reinsertion.
set isnew [dict get $extractioninfo isnew]
set oldposition [dict get $extractioninfo oldposition]
unset extractioninfo
if {$isnew && $o_debugchannel ne ""} {
puts $o_debugchannel "punkcheck: no existing FILEINFO record for targetset '$o_targets' - creating"
}
#INSTALL-INPROGRESS will become INSTALL-RECORD or INSTALL-FAILED or INSTALL-SKIPPED upon finalisation
#-installer and -eventid keys are added here
set new_inprogress_record [dict create tag [string toupper $operation]-INPROGRESS {*}$fields -tempcontext [my as_record] body {}]
#todo - look for existing "-INPROGRESS" records - mark as failed or incomplete?
dict lappend o_fileset_record body $new_inprogress_record
if {$o_operation ne "QUERY" && $o_persistence eq "eager"} {
#reinsert the inprogress-marked record and persist - crash evidence in the file during long operations
if {$isnew} {
lappend record_list $o_fileset_record
} else {
ledit record_list $oldposition -1 $o_fileset_record
}
$o_installer set_recordlist $record_list
$o_installer save_working_recordset "targetset_init $o_operation [llength $targetset] targets"
}
#deferred: the *-INPROGRESS state stays event-local (o_fileset_record) - only finalized records
#reach the working recordset (targetset_end), mirroring the pre-G-094 batch recordset handling.
return $o_fileset_record
}
#operation has been started
#todo - upgrade .punkcheck format to hold more than just list of SOURCE entries in each record.
# - allow arbitrary targetset_startphase <name> targetset_endphase <name> calls to store timestamps and calculate elapsed time
method targetset_started {} {
if {$o_operation_start_ts eq ""} {
error "[self] targetset_started - no current operation - call targetset_init first"
}
if {![punkcheck::lib::is_file_record_inprogress $o_fileset_record]} {
error "targetset_started error: bad fileset_record - expected FILEINFO with last body element *-INPROGRESS"
}
set fileinfo_body [dict get $o_fileset_record body] ;#body of FILEINFO record
set installing_record [lindex $fileinfo_body end]
set ts_start [dict get $installing_record -ts]
set ts_now [clock microseconds]
set metadata_us [expr {$ts_now - $ts_start}]
dict set installing_record -metadata_us $metadata_us
dict set installing_record -ts_start_transfer $ts_now
lset fileinfo_body end $installing_record
dict set o_fileset_record body $fileinfo_body
if {$o_operation ne "QUERY" && $o_persistence eq "eager"} {
$o_installer load_all_records
my MergeFilesetToWorking
$o_installer save_working_recordset "targetset_started $o_operation [llength $o_targets] targets"
}
return $o_fileset_record
}
method targetset_end {status args} {
set defaults [dict create {*}{
-note \uFFFF
}]
set known_opts [dict keys $defaults]
if {[llength $args] % 2} {
error "targetset_end arguments after status must be in the form of -flag value pairs. known flags: $known_opts"
}
set opts [dict merge $defaults $args]
if {[dict get $opts -note] eq "\uFFFF"} {
dict unset opts -note
}
set status [string toupper $status]
set statusdict [dict create OK RECORD SKIPPED SKIPPED FAILED FAILED]
if {$o_operation_start_ts eq ""} {
error "[self] targetset_end $status - no current operation - call targetset_started first"
}
if {$status ni [dict keys $statusdict]} {
error "[self] targetset_end unrecognized status:$status known values: [dict keys $statusdict]"
}
if {![punkcheck::lib::is_file_record_inprogress $o_fileset_record]} {
error "targetset_end $status error: bad fileset_record - expected FILEINFO with last body element *-INPROGRESS"
}
set targetlist [dict get $o_fileset_record -targets]
if {$targetlist ne $o_targets} {
error "targetset_end $status error. targetlist mismatch between file : $targetlist vs $o_targets"
}
set operation_end_ts [clock microseconds]
set elapsed_us [expr {$operation_end_ts - $o_operation_start_ts}]
set file_record_body [dict get $o_fileset_record body]
set installing_record [lindex $file_record_body end]
set punkcheck_file [$o_installer get_checkfile]
set punkcheck_folder [file dirname $punkcheck_file]
if {[dict exists $installing_record -ts_start_transfer]} {
set ts_start_transfer [dict get $installing_record -ts_start_transfer]
set transfer_us [expr {$operation_end_ts - $ts_start_transfer}]
dict set installing_record -transfer_us $transfer_us
}
if {[dict exists $opts -note]} {
dict set installing_record -note [dict get $opts -note]
}
dict set installing_record -elapsed_us $elapsed_us
dict unset installing_record -tempcontext
dict set installing_record tag "${o_operation}-[dict get $statusdict $status]" ;# e.g INSTALL-RECORD, INSTALL-SKIPPED
if {$o_operation in [list INSTALL MODIFY] && [dict get $statusdict $status] eq "RECORD"} {
#only calculate and store post operation target cksums on successful INSTALL or MODIFY, doesn't make sense for DELETE or VIRTUAL operations
set new_targets_cksums [list] ;#ordered list of cksums matching targetset order
set cksum_all_opts "" ;#same cksum opts for each target so we store it once
set ts_begin_cksum [clock microseconds]
foreach p $o_targets {
set tgt_cksum_info [punk::mix::base::lib::cksum_path [file join $punkcheck_folder $p]]
lappend new_targets_cksums [dict get $tgt_cksum_info cksum]
if {$cksum_all_opts eq ""} {
set cksum_all_opts [dict get $tgt_cksum_info opts]
}
}
set cksum_us [expr {[clock microseconds] - $ts_begin_cksum}]
dict set installing_record -targets_cksums $new_targets_cksums
dict set installing_record -cksum_all_opts $cksum_all_opts
dict set installing_record -cksum_us $cksum_us
}
lset file_record_body end $installing_record
dict set o_fileset_record body $file_record_body
set o_fileset_record [punkcheck::recordlist::file_record_prune $o_fileset_record]
if {$o_operation ne "QUERY"} {
if {$o_persistence eq "eager"} {
$o_installer load_all_records
my MergeFilesetToWorking
$o_installer save_working_recordset "targetset_end $o_operation $status [llength $o_targets] targets"
} else {
#deferred - finalized record joins the in-memory working recordset; persisted at flush
my MergeFilesetToWorking
}
}
set o_operation_start_ts ""
set o_operation ""
return $o_fileset_record
}
#can supply empty cksum value
# - that will influence the opts used if there is no existing install record
method targetset_cksumcache_set {path_cksum_dict} {
set o_path_cksum_cache $path_cksum_dict
}
method targetset_cksumcache_configure {path {cksuminfodict {}}} {
if {$cksuminfodict eq {}} {
if {[dict exists $o_path_cksum_cache $path]} {
return [dict get $o_path_cksum_cache $path]
} else {
return
}
}
dict for {k v} $cksuminfodict {
switch -- $k {
cksum - opts {}
default {
error "targetset_cksumcache_configure error. Unknown dict value $k. Allowed values {cksum opts}"
}
}
}
dict set o_path_cksum_cache $path $cksuminfodict
}
method targetset_addsource {source_path} {
set punkcheck_file [$o_installer get_checkfile]
set punkcheck_folder [file dirname $punkcheck_file]
if {[file pathtype $source_path] eq "absolute"} {
set rel_source_path [punkcheck::lib::path_relative $punkcheck_folder $source_path]
} else {
set rel_source_path $source_path
}
#installfile_add_source_and_fetch_metadata accepts list of {cksum <val> opt <cksum opts>} dictionaries - although we only have one per path from our configured cksumcache
if {[dict exists $o_path_cksum_cache $rel_source_path]} {
set path_cksum_caches [list [dict get $o_path_cksum_cache $rel_source_path]]
} else {
set path_cksum_caches [list]
}
set o_fileset_record [punkcheck::installfile_add_source_and_fetch_metadata $punkcheck_folder $rel_source_path $o_fileset_record $path_cksum_caches]
#JJJ - update -metadata_us here?
}
#add a named value (not a filesystem path) as a source - e.g the resolved build version of a module target.
#compared by -value against the last completed install record, so it participates in targetset_source_changes.
method targetset_addsource_virtual {id value} {
set o_fileset_record [punkcheck::installsource_add_virtual $o_fileset_record $id $value]
}
method targetset_last_complete {} {
#retrieve last completed record for the fileset ie exclude SKIPPED,INSTALL-INPROGRESS,DELETE-INPROGRESS,MODIFY-INPROGRESS
set body [punkcheck::dict_getwithdefault $o_fileset_record body [list]]
set previous_records [lrange $body 0 end]
#get last that is tagged INSTALL-RECORD,MODIFY-RECORD,DELETE-RECORD
set revlist [lreverse $previous_records]
foreach rec $revlist {
if {[dict get $rec tag] in [list "INSTALL-RECORD" "MODIFY-RECORD" "DELETE-RECORD" "VIRTUAL-RECORD"]} {
return $rec
}
}
return [list]
}
method targetset_source_changes {} {
punkcheck::recordlist::file_install_record_source_changes [lindex [dict get $o_fileset_record body] end]
}
}
oo::class create installtrack {
variable o_name
variable o_tsiso
variable o_ts
variable o_keep_events
variable o_checkfile
variable o_sourceroot
variable o_rel_sourceroot
variable o_targetroot
variable o_rel_targetroot
variable o_record_list
variable o_active_event
variable o_events
variable o_debugchannel
variable o_event_lock ;#advisory lock token while an event (or transient flush) holds it
variable o_touched_targets ;#targetlists this instance's events merged - the 'own records' set for merge-on-flush
variable o_load_sig ;#file signature {mtime size} at last load - optimistic tripwire vs non-protocol writers
constructor {installername punkcheck_file {debugchannel ""}} {
set o_debugchannel $debugchannel
set o_active_event ""
set o_event_lock ""
set o_touched_targets [dict create]
set o_load_sig ""
set o_name $installername
set o_checkfile [file normalize $punkcheck_file]
set o_sourceroot ""
set o_targetroot ""
set o_rel_sourceroot ""
set o_rel_targetroot ""
set o_record_list [list]
#todo - validate punkcheck file location further??
set punkcheck_folder [file dirname $o_checkfile]
if {![file isdirectory $punkcheck_folder]} {
error "[self] constructor error. Folder for punkcheck_file not found - $o_checkfile"
}
my load_all_records
if {![llength $o_record_list] && $o_debugchannel ne ""} {
puts $o_debugchannel "\x1b\[32mNo existing records found in punkcheck file '$o_checkfile' for installer '$installername'. Starting with empty record list.\x1b\[m"
} else {
#verify no duplicate installer records for this installer.
#JMN
set sanity_dict [dict create]
set insane ""
foreach rec $o_record_list {
if {[dict get $rec tag] eq "INSTALLER"} {
set name [dict get $rec -name]
if {[dict exists $sanity_dict $name]} {
#todo - warn - duplicate record for same targetlist - shouldn't happen as we should be using get_file_record to find existing records
if {$o_debugchannel ne ""} {
puts $o_debugchannel "\x1b\[31mpunkcheck installtrack - multiple INSTALLER records with same name '$name'\x1b\[m"
}
set insane "$name"
break
}
dict set sanity_dict $name {}
}
}
if {$insane ne ""} {
if {![punkcheck::lib::stdin_is_interactive]} {
#G-095: unattended runs get a clean actionable error instead of a prompt
#that would block on open stdin or mis-answer on closed/piped stdin
set errmsg "punkcheck installtrack sanity check failed: punkcheck file '$o_checkfile' contains multiple INSTALLER records with -name '$insane'."
append errmsg \n "Likely cause: concurrent writers racing the file (writers predating the G-095 lock protocol) or an interrupted run."
append errmsg \n "Session is non-interactive - not prompting. Recovery: inspect the file, or delete it and rerun."
append errmsg \n "Deleting loses install history and checksums (unnecessary recopies next run); for synced-targets consumers (e.g bin/ deploys) existing targets then skip until manually reconciled, since skipped records never store target cksums."
error $errmsg
}
set msg "Sanity check: punkcheck file '$o_checkfile' contains multiple records for INSTALLER -name '$insane'."
append msg \n "This may indicate a problem such as multiple concurrent installtrack instances using the same punkcheck file,"
append msg \n " or a previous installtrack instance that did not complete properly."
append msg \n " Do you want to DELETE the .punkcheck file?"
append msg \n " It is safe to delete .punkcheck files, at the cost of loss of history and checksums used to optimize installs."
append msg \n " They are a record of installation events and checksums used to avoid unnecessary reinstalls."
append msg \n " Caveat: for synced-targets consumers (e.g bin/ deploys) deletion wedges existing targets into skip-until-manually-reconciled - skipped records never store the target cksums that mode compares against."
append msg \n " If not confirmed, an error will be raised - likely aborting the current operation."
append msg \n "confirm deletion and continue by regenerating the file, by typing the 3 letters: 'yes'."
set answer [punk::lib::askuser $msg]
if {[string tolower $answer] ne "yes"} {
error "Failing due to sanity check failure. User did not confirm with 'yes'."
}
if {[file exists $o_checkfile] && [file isfile $o_checkfile]} {
file delete $o_checkfile
}
if {[file exists $o_checkfile]} {
error "Failed to delete punkcheck file '$o_checkfile' after sanity check failure. Please investigate and resolve the issue before proceeding."
}
set o_record_list [list]
} else {
if {$o_debugchannel ne ""} {
puts $o_debugchannel "\x1b\[32mSanity check passed: no duplicate INSTALLER records found for installer '$installername' in punkcheck file '$o_checkfile'.\x1b\[m"
}
}
unset sanity_dict
}
set resultinfo [punkcheck::recordlist::get_installer_record $o_name $o_record_list]
set existing_header_posn [dict get $resultinfo position]
if {$existing_header_posn == -1} {
set this_installer_record [punkcheck::recordlist::new_installer_record $o_name]
#set o_record_list [linsert $o_record_list 0 $this_installer_record]
ledit o_record_list -1 -1 $this_installer_record
} else {
set this_installer_record [dict get $resultinfo record]
}
set o_tsiso [dict get $this_installer_record -tsiso]
set o_ts [dict get $this_installer_record -ts]
set o_keep_events [dict get $this_installer_record -keep_events]
set o_events [oolib::collection create [namespace current]::eventcollection]
set eventlist [punkcheck::dict_getwithdefault $this_installer_record body [list]]
foreach e $eventlist {
#map the persisted EVENT record fields to constructor options explicitly.
#(the record keys are -ts_begin/-ts_end - passing the raw record gave the constructor's
# -tsbegin/-tsend defaults, so reconstructed events took 'now' as begin and empty end,
# and any later flush/save_installer_record rewrote prior event history with those values)
set eargs [list]
lappend eargs -id [punkcheck::dict_getwithdefault $e -id ""]
lappend eargs -tsbegin [punkcheck::dict_getwithdefault $e -ts_begin ""]
lappend eargs -tsend [punkcheck::dict_getwithdefault $e -ts_end ""]
lappend eargs -types [punkcheck::dict_getwithdefault $e -types {}]
lappend eargs -config [punkcheck::dict_getwithdefault $e -config {}]
set eobj [punkcheck::installevent create [namespace current]::event_[my events count] [self] [dict get $e -source] [dict get $e -targets] {*}$eargs]
$o_events add $eobj [dict get $e -id]
}
}
destructor {
#release any lock still held (an abandoned event, or an error path that skipped end)
if {$o_event_lock ne ""} {
catch {punkcheck::lock::release $o_event_lock}
set o_event_lock ""
}
}
method test {} {
return [self]
}
method get_name {} {
return $o_name
}
method get_checkfile {} {
return $o_checkfile
}
method get_debugchannel {} {
return $o_debugchannel
}
#call set_source_target before calling start_event/end_event
#each event can have different source->target pairs - but may often have same, so set on installtrack as defaults. Only persisted in event records.
method set_source_target {sourceroot targetroot} {
if {[file pathtype $sourceroot] ne "absolute"} {
error "[self] set_source_target error: sourceroot must be absolute path. Received '$sourceroot'"
}
if {[file pathtype $targetroot] ne "absolute"} {
error "[self] set_source_target error: targetroot must be absolute path. Received '$targetroot'"
}
set punkcheck_folder [file dirname $o_checkfile]
set o_sourceroot $sourceroot
set o_targetroot $targetroot
set o_rel_sourceroot [punkcheck::lib::path_relative $punkcheck_folder $sourceroot]
set o_rel_targetroot [punkcheck::lib::path_relative $punkcheck_folder $targetroot]
return [list $o_rel_sourceroot $o_rel_targetroot]
}
#review/fix to allow multiple installtrack objects on same punkcheck file.
method load_all_records {} {
set o_record_list [punkcheck::load_records_from_file $o_checkfile]
set o_load_sig [my Checkfile_signature]
}
#file signature for the optimistic tripwire ({} when the file doesn't exist yet)
method Checkfile_signature {} {
if {[catch {list [file mtime $o_checkfile] [file size $o_checkfile]} sig]} {
return {}
}
return $sig
}
#does not include associated FILEINFO records - as a targetset (FILEINFO record) can be associated with events from multiple installers over time.
#e.g a logfile common to installers, or a separate installer that updates a previous output.
method as_record {} {
set eventrecords [list]
foreach eobj [my events items] {
lappend eventrecords [$eobj as_record]
}
set fields [list {*}{
} -tsiso $o_tsiso {*}{
} -ts $o_ts {*}{
} -name $o_name {*}{
} -keep_events $o_keep_events {*}{
} body $eventrecords {*}{
}
]
set record [dict create tag INSTALLER {*}$fields]
}
#working recordset accessors - the installtrack's o_record_list is the working copy of the
#.punkcheck recordset. Eager operations and start_event refresh it from the file; deferred
#operations treat it as authoritative between flushes.
method set_recordlist {records} {
set o_record_list $records
}
#record a targetlist as touched by this instance's events - the 'own records' set that
#merge-on-flush carries into freshly-loaded file state
method touched_target {targetlist} {
dict set o_touched_targets $targetlist 1
}
method lock_held {} {
return [expr {$o_event_lock ne ""}]
}
#single save chokepoint: every .punkcheck write from the record lifecycle funnels through here.
#G-095 measures live here: the write itself is write-temp-then-atomic-rename
#(save_records_to_file), a caller without the event lock gets a transient one, and the
#optimistic tripwire warns when the file changed on disk since this installer last loaded
#it (a writer bypassing the advisory protocol - e.g an older vendored punkcheck during
#the transition window).
method save_working_recordset {{trigger save_working_recordset}} {
set transient 0
if {$o_event_lock eq ""} {
set o_event_lock [punkcheck::lock::acquire $o_checkfile -installer $o_name]
set transient 1
}
try {
set current_sig [my Checkfile_signature]
if {$o_load_sig ne {} && $current_sig ne {} && $current_sig ne $o_load_sig} {
catch {puts stderr "punkcheck warning: '$o_checkfile' changed on disk since this installer last loaded it (advisory lock protocol bypassed by another writer?) - saving anyway (trigger: $trigger installer: $o_name)"}
}
set saveresult [punkcheck::save_records_to_file $o_record_list $o_checkfile $trigger $o_debugchannel]
set o_load_sig [my Checkfile_signature]
return $saveresult
} finally {
if {$transient} {
punkcheck::lock::release $o_event_lock
set o_event_lock ""
}
}
}
#sync this installer's own records into freshly-loaded file state and persist via the
#chokepoint - the event-scoped persistence point for deferred events.
#Merge-on-flush (G-095): the file is re-loaded under the lock and only this installer's
#own records are carried over - the INSTALLER record by -name and touched FILEINFO
#records by -targets - so records other processes wrote since our load are preserved
#instead of being wholesale-overwritten.
method flush {{trigger flush}} {
set transient 0
if {$o_event_lock eq ""} {
set o_event_lock [punkcheck::lock::acquire $o_checkfile -installer $o_name]
set transient 1
}
try {
#fresh file state under the lock
set base [punkcheck::load_records_from_file $o_checkfile]
#merge own FILEINFO records (by -targets)
foreach targetlist [dict keys $o_touched_targets] {
set owninfo [punkcheck::recordlist::get_file_record $targetlist $o_record_list]
if {[dict get $owninfo position] == -1} {
continue
}
set ownrec [dict get $owninfo record]
set baseinfo [punkcheck::recordlist::get_file_record $targetlist $base]
set base_posn [dict get $baseinfo position]
if {$base_posn == -1} {
lappend base $ownrec
} else {
lset base $base_posn $ownrec
}
}
#merge own INSTALLER record (by -name), event-pruned against the merged set
set this_installer_record [my as_record]
set persistedinfo [punkcheck::recordlist::get_installer_record $o_name $base]
set existing_header_posn [dict get $persistedinfo position]
if {$existing_header_posn == -1} {
ledit base -1 -1 $this_installer_record
set existing_header_posn 0
} else {
lset base $existing_header_posn $this_installer_record
}
set this_installer_record [punkcheck::recordlist::installer_record_pruneevents $this_installer_record $base]
lset base $existing_header_posn $this_installer_record
set o_record_list $base
set o_load_sig [my Checkfile_signature]
my save_working_recordset $trigger
} finally {
if {$transient} {
punkcheck::lock::release $o_event_lock
set o_event_lock ""
}
}
}
#save the working recordset (installer record synced) - historical name retained.
method save_all_records {} {
my flush save_all_records
}
method save_installer_record {} {
my flush save_installer_record
}
method events {args} {
tailcall $o_events {*}$args
}
method start_event {configdict args} {
if {$o_active_event ne ""} {
error "[self] start_event error - event already started: $o_active_event"
}
if {$o_rel_sourceroot eq "" || $o_rel_targetroot eq ""} {
error "[self] No configured sourceroot or targetroot. Call [self] set_source_target <abspath_sourceroot> <abspath_targetroot> first"
}
if {[llength $configdict] %2 != 0} {
error "[self] start_event configdict must have an even number of elements"
}
set defaults [dict create -persistence eager]
if {[llength $args] % 2} {
error "[self] start_event arguments after configdict must be -flag value pairs. known flags: [dict keys $defaults]"
}
foreach {k v} $args {
if {$k ni [dict keys $defaults]} {
error "[self] start_event unrecognised flag '$k'. known flags: [dict keys $defaults]"
}
}
set opts [dict merge $defaults $args]
set persistence [dict get $opts -persistence]
#G-095: acquire the advisory event lock before reading - held from here until the
#event ends (event_ended releases). This serializes whole installer events per
#punkcheck root across processes; in-process sequential installers re-enter via the
#lock registry's refcount.
set acquired_here 0
if {$o_event_lock eq ""} {
set o_event_lock [punkcheck::lock::acquire $o_checkfile -installer $o_name]
set acquired_here 1
}
try {
#refresh the working recordset from the file so this event starts from persisted state.
#(coherence: a prior event's targetset writes - eager per-op saves or a deferred flush - must not
# be discarded by saving a stale constructor-era recordset here. Unflushed deferred updates are
# deliberately abandoned by this refresh - an ended deferred event that wasn't flushed chose not
# to persist.)
my load_all_records
set resultinfo [punkcheck::recordlist::get_installer_record $o_name $o_record_list]
set existing_header_posn [dict get $resultinfo position]
if {$existing_header_posn == -1} {
#no persisted installer record yet (first event since construction - the constructor only
#creates the record in memory; this save is the first write)
set this_installer_record [punkcheck::recordlist::new_installer_record $o_name]
ledit o_record_list -1 -1 $this_installer_record
set existing_header_posn 0
} else {
set this_installer_record [dict get $resultinfo record]
}
set eventobj [punkcheck::installevent create [namespace current]::event_[my events count] [self] $o_rel_sourceroot $o_rel_targetroot -config $configdict -persistence $persistence]
set eventid [$eventobj get_id]
set event_record [$eventobj as_record]
set this_installer_record [punkcheck::recordlist::installer_record_add_event $this_installer_record $event_record]
set this_installer_record [punkcheck::recordlist::installer_record_pruneevents $this_installer_record $o_record_list]
#replace
lset o_record_list $existing_header_posn $this_installer_record
my save_working_recordset "start_event $eventid"
set o_active_event $eventobj
my events add $eventobj $eventid
return $eventobj
} on error {result erropts} {
if {$acquired_here && $o_event_lock ne ""} {
catch {punkcheck::lock::release $o_event_lock}
set o_event_lock ""
}
return -options $erropts $result
}
}
method installer_record_from_file {} {
set resultinfo [punkcheck::recordlist::get_installer_record $o_name $o_record_list]
}
method get_recordlist {} {
return $o_record_list
}
method end_event {} {
if {$o_active_event eq ""} {
error "[self] end_event error - no active event"
}
$o_active_event end
}
#called by installevent end - clears the active slot so a subsequent start_event on this
#installtrack is valid (sequential events on one instance), and releases the advisory
#event lock (G-095: lock scope is the whole installer event, start_event through end)
method event_ended {eventobj} {
if {$o_active_event eq $eventobj} {
set o_active_event ""
if {$o_event_lock ne ""} {
catch {punkcheck::lock::release $o_event_lock}
set o_event_lock ""
}
}
}
method get_event {} {
return $o_active_event
}
}
}
#-----------------------------------------------
#G-094: the legacy proc pipeline (start_installer_event + installfile_begin/started/finished/skipped)
#is retired. The installtrack/installevent OO layer above is the sole implementation of the record
#lifecycle. These shims error with a pointer so stale callers fail loud and actionable.
proc start_installer_event {args} {
error "punkcheck::start_installer_event retired (G-094). Use: set obj \[punkcheck::installtrack new <name> <punkcheckfile>\]; \$obj set_source_target <src> <tgt>; \$obj start_event <configdict> ?-persistence eager|deferred? - see punkcheck::installfile_help"
}
proc installfile_begin {args} {
error "punkcheck::installfile_begin retired (G-094). Use the installevent method: \$event targetset_init INSTALL <targetset> - see punkcheck::installfile_help"
}
proc installfile_started_install {args} {
error "punkcheck::installfile_started_install retired (G-094). Use the installevent method: \$event targetset_started - see punkcheck::installfile_help"
}
proc installfile_finished_install {args} {
error "punkcheck::installfile_finished_install retired (G-094). Use the installevent method: \$event targetset_end OK - see punkcheck::installfile_help"
}
proc installfile_skipped_install {args} {
error "punkcheck::installfile_skipped_install retired (G-094). Use the installevent method: \$event targetset_end SKIPPED - see punkcheck::installfile_help"
}
#-----------------------------------------------
proc installfile_help {} {
set msg ""
append msg "punkcheck record lifecycle - installtrack/installevent object API (G-094)" \n
append msg "The legacy proc pipeline (start_installer_event, installfile_begin," \n
append msg "installfile_started_install, installfile_finished_install, installfile_skipped_install)" \n
append msg "is retired - those procs now raise errors pointing here." \n
append msg "" \n
append msg "Call in order:" \n
append msg " set installer \[punkcheck::installtrack new <installername> <path/.punkcheck>\]" \n
append msg " \$installer set_source_target <abs_sourceroot> <abs_targetroot>" \n
append msg " set event \[\$installer start_event <configdict> ?-persistence eager|deferred?\]" \n
append msg " per targetset:" \n
append msg " \$event targetset_init <QUERY|INSTALL|MODIFY|DELETE|VIRTUAL> <targetset>" \n
append msg " \$event targetset_addsource <path> (1+ times - records source metadata/cksums)" \n
append msg " \$event targetset_addsource_virtual <id> <value> (named-value sources e.g build versions)" \n
append msg " decide skip/proceed via \$event targetset_source_changes and \$event get_targets_exist" \n
append msg " \$event targetset_started (immediately before performing the operation)" \n
append msg " \$event targetset_end OK|SKIPPED|FAILED ?-note <note>?" \n
append msg " \$event end (or \$installer end_event - allows a subsequent start_event)" \n
append msg "" \n
append msg "Persistence policy (chosen per event at start_event):" \n
append msg " eager (default): each non-QUERY targetset operation is an immediate read-modify-write" \n
append msg " of the .punkcheck file (crash evidence via *-INPROGRESS records)." \n
append msg " deferred: targetset operations update the installtrack's in-memory recordset only;" \n
append msg " call \$installer flush to persist (event-scoped batch save - punkcheck::install uses this)." \n
append msg " An ended deferred event that was never flushed deliberately discards its record updates." \n
append msg " QUERY operations are never persisted in either mode." \n
append msg "" \n
append msg "Concurrent-writer safety (G-095):" \n
append msg " Saves are write-temp-then-atomic-rename - readers never see torn content." \n
append msg " start_event acquires the advisory sibling lockfile <punkcheckfile>.lock (released at event" \n
append msg " end), serializing whole installer events per punkcheck root across processes; flushes and" \n
append msg " chokepoint saves outside an event take a transient lock. Contention retries with backoff" \n
append msg " until timeout, then errors naming the holder (pid/host/installer/timestamp from the" \n
append msg " lockfile). Stale locks break on provably-dead holder pid (capability-gated) or age." \n
append msg " Env overrides: PUNKCHECK_LOCK_TIMEOUT (ms, default 15000), PUNKCHECK_LOCK_STALEAGE" \n
append msg " (seconds, default 300). Deferred flushes merge own records (INSTALLER by -name," \n
append msg " touched FILEINFO by -targets) into freshly-loaded file state." \n
return $msg
}
#todo - ensure that removing a dependency is noticed as a change
#e.g previous installrecord had 2 source records - but we now only depend on one.
#The files we depended on for the previous record haven't changed themselves - but the list of files has (reduced by one)
#cached_cksums is list of dicts with keys cksum & opts
#Will only be used if any opts values present match those from file_record's -cksum_all_opts (in last install record) or first cached_cksum will be used if no last install record values
proc installfile_add_source_and_fetch_metadata {punkcheck_folder source_relpath file_record {cached_cksums {}}} {
if {![lib::is_file_record_inprogress $file_record]} {
error "installfile_add_source_and_fetch_metadata error: bad file_record - expected FILEINFO with last body element *-INPROGRESS ($file_record)"
}
#validate any passed cached_cksums
foreach cacheinfo $cached_cksums {
if {[llength $cacheinfo] % 2 != 0} {
error "installfile_add_source_and_fetch_metadata error.If cached_cksums is supplied, it must be a list of dicts containing keys cksum & opts"
}
dict for {k v} $cacheinfo {
switch -- $k {
cksum {}
opts {
#todo - validate $v keys
}
default {
error "installfile_add_source_and_fetch_metadata error. Unrecognised key $k. Known keys {cksum opts}"
}
}
}
}
set ts_start [clock microseconds]
set last_installrecord [lib::file_record_get_last_installrecord $file_record]
set prev_ftype ""
set prev_fsize ""
set prev_cksum ""
set prev_cksum_opts ""
if {[llength $last_installrecord]} {
set src [lib::install_record_get_matching_source_record $last_installrecord $source_relpath]
if {[llength $src]} {
if {[dict_getwithdefault $src -path ""] eq $source_relpath} {
set prev_ftype [dict_getwithdefault $src -type ""]
set prev_fsize [dict_getwithdefault $src -size ""]
set prev_cksum [dict_getwithdefault $src -cksum ""]
set prev_cksum_opts [dict_getwithdefault $src -cksum_all_opts ""]
}
}
}
#check that this relpath not already added as child of *-INPROGRESS
set file_record_body [dict_getwithdefault $file_record body [list]] ;#new file_record may have no body
set installing_record [lindex $file_record_body end]
set already_present_record [lib::install_record_get_matching_source_record $installing_record $source_relpath]
if {[llength $already_present_record]} {
error "installfile_add_source_and_fetch_metadata error: source path $source_relpath already exists in the file_record - cannot add again"
}
set use_cache 0
if {$prev_cksum_opts ne ""} {
set cksum_opts $prev_cksum_opts
#find first cached_cksum that is compatible with cksum opts used in latest install record
foreach cacheinfo $cached_cksums {
set cachedopts [dict get $cacheinfo opts]
set cache_is_match 1
dict for {k v} $cachedopts {
if {[dict exists $prev_cksum_opts $k] && $v ne [dict get $prev_cksum_opts $k]} {
set cache_is_match 0
break
}
}
if {$cache_is_match} {
set use_cache_record $cacheinfo
set use_cache 1
break
}
}
} else {
#no cksum opts available from an install record
set cksum_opts ""
#use first entry in cached_cksums if we can
if {[llength $cached_cksums]} {
set use_cache 1
set use_cache_record [lindex $cached_cksums 0]
}
}
#todo - accept argument of cached source cksum info (for client calling multiple targets with same source in quick succession e.g when building .vfs kits with multiple runtimes)
#if same cksum_opts - then use cached data instead of checksumming here.
#allow nonexistant as a source
set fpath [file join $punkcheck_folder $source_relpath]
#windows: file exist + file type = 2ms vs 500ms for 2x glob
set floc [file dirname $fpath]
set fname [file tail $fpath]
set file_set [glob -nocomplain -dir $floc -type f -tails -- $fname]
set dir_set [glob -nocomplain -dir $floc -type d -tails -- $fname]
set link_set [glob -nocomplain -dir $floc -type l -tails -- $fname]
if {[llength $file_set] == 0 && [llength $dir_set] == 0 && [llength $link_set] == 0} {
#could also theoretically exist as less common types, b,c,p,s (block,char,pipe,socket)
#- we don't expect them here - REVIEW - ever possible?
#- installing/examining such things an unlikely usecase and would require special handling anyway.
set ftype "missing"
set fsize ""
} else {
if {[llength $dir_set]} {
set ftype "directory"
set fsize "NA"
} elseif {[llength $link_set]} {
set ftype "link"
set fsize 0
} else {
set ftype "file"
#todo - optionally use mtime instead of cksum (for files only)?
#mtime is not reliable across platforms and filesystems though.. see article linked at top.
set fsize [file size $fpath]
}
}
#if {![file exists $fpath]} {
# set ftype "missing"
# set fsize ""
#} else {
# set ftype [file type $fpath]
# if {$ftype eq "directory"} {
# set fsize "NA"
# } else {
# #todo - optionally use mtime instead of cksum (for files only)?
# #mtime is not reliable across platforms and filesystems though.. see article linked at top.
# set fsize [file size $fpath]
# }
#}
#get_relativecksum_from_base and fill_relativecksums_from_base_and_relativepathdict will set cksum to <PATHNOTFOUND> if fpath doesn't exist
if {[file pathtype $source_relpath] eq "absolute"} {
#source shares no common root with the punkcheck folder, so path_relative left it absolute
#(e.g a //zipfs:/ module-mounted layout installing to the filesystem - G-087 module-carried layouts).
#Use the helpers' independent-absolute mode (empty base) - they error on unrelatable roots otherwise.
set ck_base ""
} else {
set ck_base $punkcheck_folder
}
if {$use_cache} {
set source_cksum_info [punk::mix::base::lib::fill_relativecksums_from_base_and_relativepathdict $ck_base [dict create $source_relpath $use_cache_record]]
} else {
set source_cksum_info [punk::mix::base::lib::get_relativecksum_from_base $ck_base $source_relpath {*}$cksum_opts]
}
lassign $source_cksum_info pathkey ckinfo
if {$pathkey ne $source_relpath} {
error "installfile_add_source_and_fetch_metadata error: cksum returned wrong path info '$pathkey' expected '$source_relpath'"
}
set cksum [dict get $ckinfo cksum]
#set cksum_all_opts [dict get $ckinfo cksum_all_opts]
set cksum_all_opts [dict get $ckinfo opts]
if {$cksum ne $prev_cksum || $ftype ne $prev_ftype || $fsize ne $prev_fsize} {
set changed 1
} else {
set changed 0
}
set installing_record_sources [dict_getwithdefault $installing_record body [list]]
set ts_now [clock microseconds] ;#gathering metadata - especially checksums on folder can take some time - calc and store elapsed us for time taken to gather metadata
set metadata_us [expr {$ts_now - $ts_start}]
set this_source_record [dict create tag SOURCE -type $ftype -size $fsize -path $source_relpath -cksum $cksum -cksum_all_opts $cksum_all_opts -changed $changed -metadata_us $metadata_us]
lappend installing_record_sources $this_source_record
dict set installing_record body $installing_record_sources
lset file_record_body end $installing_record
dict set file_record body $file_record_body
return $file_record
}
#Add a 'virtual' source to the current *-INPROGRESS record - a named value rather than a filesystem path.
#Stored as a SOURCE record with -type virtual and -path virtual:<id> so it can never collide with a real relative path.
#The -changed flag is computed by comparing -value against the matching virtual SOURCE in the last completed
#install record (no filesystem access) - so virtual sources participate in targetset_source_changes like file sources.
#Typical use: recording the resolved build version of a module target, so records identify which product of an
#unchanging source fileset (e.g <module>-0.6.0.tm + <module>-buildversion.txt) the target represents.
proc installsource_add_virtual {file_record id value} {
if {![lib::is_file_record_inprogress $file_record]} {
error "installsource_add_virtual error: bad file_record - expected FILEINFO with last body element *-INPROGRESS ($file_record)"
}
set ts_start [clock microseconds]
set virtualpath virtual:$id
set last_installrecord [lib::file_record_get_last_installrecord $file_record]
set prev_found 0
set prev_value ""
if {[llength $last_installrecord]} {
set src [lib::install_record_get_matching_source_record $last_installrecord $virtualpath]
if {[llength $src]} {
set prev_found 1
set prev_value [dict_getwithdefault $src -value ""]
}
}
set file_record_body [dict_getwithdefault $file_record body [list]]
set installing_record [lindex $file_record_body end]
set already_present_record [lib::install_record_get_matching_source_record $installing_record $virtualpath]
if {[llength $already_present_record]} {
error "installsource_add_virtual error: virtual source $virtualpath already exists in the file_record - cannot add again"
}
if {$prev_found && $prev_value eq $value} {
set changed 0
} else {
set changed 1
}
set installing_record_sources [dict_getwithdefault $installing_record body [list]]
set metadata_us [expr {[clock microseconds] - $ts_start}]
set this_source_record [dict create tag SOURCE -type virtual -size NA -path $virtualpath -value $value -changed $changed -metadata_us $metadata_us]
lappend installing_record_sources $this_source_record
dict set installing_record body $installing_record_sources
lset file_record_body end $installing_record
dict set file_record body $file_record_body
return $file_record
}
#-----------------------------------------------
#then: file_record_add_installrecord
namespace eval lib {
set pkg punkcheck
namespace path ::punkcheck
proc is_file_record_inprogress {file_record} {
if {[dict get $file_record tag] ne "FILEINFO"} {
return 0
}
set installing_record [lindex [dict_getwithdefault $file_record body [list]] end]
if {[dict_getwithdefault $installing_record tag [list]] ni [list QUERY-INPROGRESS INSTALL-INPROGRESS MODIFY-INPROGRESS DELETE-INPROGRESS VIRTUAL-INPROGRESS]} {
return 0
}
return 1
}
proc is_file_record_installing {file_record} {
if {[dict get $file_record tag] ne "FILEINFO"} {
return 0
}
set installing_record [lindex [dict_getwithdefault $file_record body [list]] end]
if {[dict_getwithdefault $installing_record tag [list]] ne "INSTALL-INPROGRESS"} {
return 0
}
return 1
}
proc file_record_get_last_installrecord {file_record} {
set body [dict_getwithdefault $file_record body [list]]
set previous_install_records [lrange $body 0 end-1]
#get last previous that is tagged INSTALL-RECORD,MODIFY-RECORD,VIRTUAL-RECORD
#REVIEW DELETERECORD ???
set revlist [lreverse $previous_install_records]
foreach rec $revlist {
if {[dict get $rec tag] in [list "INSTALL-RECORD" "MODIFY-RECORD" "VIRTUAL-RECORD"]} {
return $rec
}
}
return [list]
}
#should work on *-INPROGRESS or INSTALL(etc) record - don't restrict tag to INSTALL
proc install_record_get_matching_source_record {install_record source_relpath} {
set body [dict_getwithdefault $install_record body [list]]
foreach src $body {
if {[dict get $src tag] eq "SOURCE"} {
if {[dict_getwithdefault $src -path ""] eq $source_relpath} {
return $src
}
}
}
return [list]
}
# Delegate to punk::path::relative (verified equivalent implementation)
proc path_relative {base dst} {
return [punk::path::relative $base $dst]
}
#Is stdin a terminal a user can be prompted on? (G-095: the duplicate-INSTALLER recovery
#must not block or garble unattended runs with an interactive prompt.)
#Detection is tiered and defaults to NOT interactive when no probe is available - the
#non-interactive path raises a clean actionable error, which an interactive user can act
#on just as well, whereas a prompt in an unattended run blocks or mis-answers.
proc stdin_is_interactive {} {
if {"windows" eq $::tcl_platform(platform)} {
#strict check on the actual std handle: GetConsoleMode errors for pipe/file/NUL
#stdin (twapi::get_console_handle is unsuitable - it succeeds for piped children
#of console shells; same finding as runtests.tcl colour autodetection)
if {![catch {package require twapi}]} {
return [expr {![catch {twapi::GetConsoleMode [twapi::GetStdHandle -10]}]}]
}
return 0
} else {
#tcl 9 (and 8.7) unix terminal channels expose -inputmode; pipes/files do not
if {[dict exists [chan configure stdin] -inputmode]} {
return 1
}
#tcl 8.6 unix: POSIX test(1) isatty on fd 0
if {![catch {exec test -t 0 <@stdin}]} {
return 1
}
return 0
}
}
}
#skip writing punkcheck during checksum/timestamp checks
#todo - punk::args - fetch from punkcheck::install (with overrides)
proc install_tm_files {srcdir basedir args} {
set defaults [list {*}{
-glob *.tm
-installer punkcheck::install_tm_files
} -exclude-filetails [list "*[punk::mix::util::tm_version_magic]*"] {*}{
}
]
set opts [dict merge $defaults $args]
punkcheck::install $srcdir $basedir {*}$opts
}
proc install_non_tm_files {srcdir basedir args} {
#set keys [dict keys $args]
#adjust the default exclude-paths_core entries so that .fossil-custom, .fossil-settings are copied
set excludepaths_core [punkcheck::default_excludepaths_core]
#remove .fossil* entries (both top-level and **/ forms) so fossil folders are not excluded
set excludepaths_core [lsearch -inline -all -not $excludepaths_core ".fossil*"]
set excludepaths_core [lsearch -inline -all -not $excludepaths_core "**/.fossil*"]
set defaults [list {*}{
} -glob * {*}{
} -exclude-filetails [list "*.tm" "*-buildversion.txt" "*.exe"] {*}{
} -exclude-paths-core $excludepaths_core {*}{
} -installer punkcheck::install_non_tm_files {*}{
}
]
set opts [dict merge $defaults $args]
punkcheck::install $srcdir $basedir {*}$opts
}
#for tcl8.6 - tcl8.7+ has dict getwithdefault (dict getdef)
proc dict_getwithdefault {dictValue args} {
if {[llength $args] < 2} {
error {wrong # args: should be "dict_getdef dictionary ?key ...? key default"}
}
set keys [lrange $args 0 end-1]
if {[dict exists $dictValue {*}$keys]} {
return [dict get $dictValue {*}$keys]
} else {
return [lindex $args end]
}
}
lappend PUNKARGS [list {
@id -id ::punkcheck::install
@cmd -name ::punkcheck::install -help\
"Unidirectional file transfer to possibly non-empty target folder.
This is the simpler form of the API, performing a transfer from one
directory tree to another, copying each file when changes in the source
file are detected.
Changes are detected by content checksum. The first install will record
source checksums in a .punkcheck file (ideally located at the root of the
target folder). Subsequent installs will compare stored checksums with
the current checksums of the source files.
For more advanced install operations, the object command installtrack
can be used to define install operations. e.g when the transfer is not
one-to-one and a target file depends on multiple source files."
@leaders -min 2 -max 2
srcdir -type directory
tgtdir -type directory
-call-depth-internal -type integer -default 0 -help "(internal recursion tracker)"
-subdirlist -type list -default "" -help "(primarily internal - length generally matching -call-depth-internal)"
-max-depth -type integer -default 1000 -help\
"Deepest subdirectory - use -1 for no limit."
-createdir -type boolean -default 0 -help\
"Whether to create the folder at tgtdir.
Any required subdirectories are created regardless of this setting."
-createempty -type boolean -default 0 -help\
"Whether to create folders at target that had no matches for our glob"
-glob -type string -default "*" -help\
"Pattern matching for source file(s) to copy. Can be glob based or exact match."
-exclude-filetails-core -default {${[::punkcheck::default_excludefiletail_core]}} -help\
"Non-overridable default file-tail exclusion patterns.
These are applied in addition to -exclude-filetails and cannot be
disabled by the caller - they can only be replaced by passing
-exclude-filetails-core with a different list.
Patterns use string match against the file tail (basename) at any
level in the tree. They must not contain path separators."
-exclude-filetails -default "" -help\
"List of glob patterns to match against file tails (basenames).
Patterns use string match and must not contain path separators.
e.g {*.swp *.tmp} excludes swap and temp files at any level."
-exclude-paths-core -default {${[::punkcheck::default_excludepaths_core]}} -help\
"Non-overridable default path-glob exclusion patterns.
These are applied in addition to -exclude-paths and cannot be
disabled by the caller - they can only be replaced by passing
-exclude-paths-core with a different list.
Patterns are matched against the full relative source path using
punk::path::globmatchpath."
-exclude-paths -default {} -help\
"List of path-glob patterns to exclude from installation.
Patterns are matched against the full relative source path using
punk::path::globmatchpath, so they may include ** to match across
path segments and / to match within a segment.
e.g to exclude all AGENTS.md files at any depth: {AGENTS.md **/AGENTS.md}"
#backward-compat aliases (accepted but deprecated - converted internally to -exclude-paths)
-exclude-dirsegments-core -default {${[::punkcheck::default_excludedirseg_core]}} -help\
"DEPRECATED: use -exclude-paths-core instead.
Legacy segment-glob patterns matched against directory tail names.
Converted internally to path-glob form by expanding each segment
S to {S **/S}."
-exclude-dirsegments -default "" -help\
"DEPRECATED: use -exclude-paths instead.
Legacy segment-glob patterns matched against directory tail names
at any level. Each pattern must not contain path separators."
-antiglob-paths -default {} -help\
"DEPRECATED: use -exclude-paths instead.
Legacy path-glob patterns matched against the full relative source
path using punk::path::globmatchpath. Merged into -exclude-paths
internally."
-overwrite -default no-targets\
-choices {no-targets newer-targets older-targets all-targets installedsourcechanged-targets synced-targets}\
-choicecolumns 1\
-choiceprefix 0\
-choicelabels {
no-targets "only copy files that are missing at the target"
newer-targets "copy files with older source timestamp over newer
target timestamp and those missing at the target
(a form of 'restore' operation)"
older-targets "copy files with newer source timestamp over older
target timestamp and those missing at the target"
all-targets "copy regardless of timestamp at target"
installedsourcechanged-targets "copy if the target doesn't exist or the source changed"
synced-targets "copy if the target doesn't exist or the source changed
and the target cksum is the same as the last INSTALL-RECORD -targets_cksums entry"
}
-source-checksum -default comparestore -choicecolumns 3 -choices {compare store comparestore false true} -choiceprefix 0 -help\
"Checksum behavior for source files.
compare: checksum and compare against last record, but do not store.
store: checksum and store, but do not compare against last record.
comparestore: checksum, compare against last record, and store.
false: do not checksum (not recommended - changes cannot be detected).
true: same as comparestore."
-punkcheck-folder -default target -choices {target source project} -choicerestricted 0 -choiceprefix 0 -help\
"The location of the .punkcheck file to track installations and checksums.
The default value 'target' is generally recommended.
Can also be an absolute path to a folder."
-punkcheck-eventid -default "" -help\
"Event ID for the install operation. When empty (default), a new UUID
is generated automatically. Pass a specific ID to group multiple
install calls under the same event in the .punkcheck file."
-punkcheck-records -default "" -help\
"DEPRECATED/ignored (G-094) - retained for call compatibility.
The working recordset lives in a punkcheck::installtrack instance for
the duration of the install walk; the passed value is not consulted
(pre-G-094 behaviour also replaced it from the file at depth 0)."
-punkcheck-eventobj -default "" -help\
"(internal recursion carrier - the installevent object shared by all
levels of the install walk)"
-installer -default "punkcheck::install" -help\
"A user nominated string that is stored in the .punkcheck file
This might be the name of a script or installation process."
-progresschannel -default none -type string -help\
"Name of channel e.g stderr, stdout to which progress messages are written.
This includes the tree-like output consisting of dots (or green U) for each
file processed. As the number of files in a tree is not known beforehand,
it isn't useful for a percentage-based progress meter, but it could potentially
be used to drive a spinner if the textual data is not desired.
Setting to none or an invalid channel will deactivate the output."
}]
## unidirectional file transfer to possibly non empty folder
#default of -overwrite no-targets will only copy files that are missing at the target
# -overwrite newer-targets will copy files with older source timestamp over newer target timestamp and those missing at the target (a form of 'restore' operation)
# -overwrite older-targets will copy files with newer source timestamp over older target timestamp and those missing at the target
# -overwrite all-targets will copy regardless of timestamp at target
# -overwrite installedsourcechanged-targets will copy if the target doesn't exist or the source changed
# -overwrite synced-targets will copy if the target doesn't exist or the source changed and the target cksum is the same as the last INSTALL-RECORD -targets_cksums entry
# review - timestamps unreliable
# - what about slightly mismatched system clocks and mounted filesystems? caller responsibility to verify first?
# if timestamp exactly equal - should we check content-hash? This is presumably only likely to occur deliberately(maliciously?)
# e.g some process that digitally signs or otherwise modifies a file and preserves update timestmp?
# if such a content-mismatch - what default behaviour and what options would make sense?
# probably it's reasonable that only all-targets would overwrite such files.
# consider -source_fudge_seconds +-X ?, -source_override_timestamp ts ??? etc which only adjust timestamp for calculation purposes? Define a specific need/usecase when reviewing.
#
# valid filetypes for src tgt
# src dir tgt dir
# todo - review and consider enabling symlink src and dst
# no need for src file - as we use -glob with no glob characters to match one source file file
# no ability to target file with different name - keep it simpler and caller will have to use an intermediate folder/file if they need to rename something?
#
# todo - determine what happens if mismatch between file type of a src vs target e.g target has dir matching filename at source
# A pre-scan to determine no such conflict - before attempting to copy anything might provide the most integrity at slight cost in speed.
# REVIEW we should only expect dirs to be created as necessary to hold files? i.e target folder won't be created if no source file matches for that folder
# -source-checksum compare|store|comparestore|false|true where true == comparestore
# -punkcheck-folder target|source|project|<absolutepath> target is default and is generally recommended
# -punkcheck-records empty string | parsed TDL records ie {tag xxx k v} structure
# install creates FILEINFO records with a single entry in the -targets field (it is legitimate to have a list of targets for an installation operation - the oo interface supports this)
proc install {srcdir tgtdir args} {
# Backward-compat: normalize old underscore flag names to hyphenated forms
set flag_aliases [dict create {*}{
-max_depth -max-depth
-exclude-filetails_core -exclude-filetails-core
-exclude-paths_core -exclude-paths-core
-exclude-dirsegments_core -exclude-dirsegments-core
-antiglob_paths -antiglob-paths
-source_checksum -source-checksum
-punkcheck_folder -punkcheck-folder
-punkcheck_eventid -punkcheck-eventid
-punkcheck_records -punkcheck-records
}]
if {[llength $args]} {
set normalized [list]
foreach {k v} $args {
if {[dict exists $flag_aliases $k]} {
lappend normalized [dict get $flag_aliases $k] $v
} else {
lappend normalized $k $v
}
}
set args $normalized
}
set defaults [list {*}{
-call-depth-internal 0
-max-depth 1000
-subdirlist {}
-createdir 0
-createempty 0
-glob *
-exclude-filetails-core "\uFFFF"
-exclude-filetails ""
-exclude-paths-core "\uFFFF"
-exclude-paths {}
-exclude-dirsegments-core "\uFFFF"
-exclude-dirsegments {}
-antiglob-paths {}
-overwrite no-targets
-source-checksum comparestore
-punkcheck-folder target
-punkcheck-eventid "\uFFFF"
-punkcheck-records ""
-punkcheck-eventobj ""
-installer punkcheck::install
-progresschannel none
}]
if {([llength $args] %2) != 0} {
error "punkcheck::install requires option-style arguments to be in pairs. Received args: $args"
}
foreach {k -} $args {
if {$k ni [dict keys $defaults]} {
error "punkcheck::install unrecognised option '$k' known options: '[dict keys $defaults]'"
}
}
set opts [dict merge $defaults $args]
#The choice to recurse using the original values of srcdir & tgtdir, and passing the subpath down as a list in -subdirlist seems an odd one.
#(as opposed to a more 'standard' mechanism of adjusting srcdir & tgtdir as we move down the tree)
#It comes from build_modules_from_source_to_base where we need to keep track of position relative to our targetdir starting point to handle submodules e.g pkg::something::mypkg-0.1.tm
#It could have been handled with some other parameter such as -depth, but this -subdirlist mechanism, whilst perhaps not beautiful, is straightforward enough
#and may be less error prone than doing slightly more opaque path manipulations at each recursion level to determine where we started
#For consistency - we'll use the same mechanism in various recursive directory walking procedures such as this one.
set CALLDEPTH [dict get $opts -call-depth-internal] ;#added for extra debug/sanity checking - clearer test for initial function call ie CALLDEPTH = 0
set max_depth [dict get $opts -max-depth] ;# -1 for no limit
set subdirlist [dict get $opts -subdirlist] ;# generally should be same length as CALLDEPTH - but user could prefill
set fileglob [dict get $opts -glob]
set createdir [dict get $opts -createdir] ;#defaults to zero to help avoid mistakes with initial target dir - required target subdirs are created regardless of this setting
set opt_createempty [dict get $opts -createempty]
set opt_progresschannel [dict get $opts -progresschannel]
if {$opt_progresschannel in {"" none} || [catch {chan configure $opt_progresschannel}]} {
set opt_progresschannel ""
}
if {$CALLDEPTH == 0} {
#expensive to normalize but we need to do it at least once
set srcdir [file normalize $srcdir]
set tgtdir [file normalize $tgtdir]
if {$createdir} {
file mkdir $tgtdir
} else {
if {![file exists $tgtdir]} {
error "punkcheck::install base target dir:'$tgtdir' doesn't exist (srcdir:$srcdir tgtdir:$tgtdir args:'$args')"
}
}
if {([file type $srcdir] ni [list directory]) || ([file type $tgtdir] ni [list directory])} {
error "punkcheck::install requires source and target dirs to be of type 'directory' type current source: [file type $srcdir] type current target: [file type $tgtdir]"
}
#now the values we build from these will be properly cased
}
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
set opt_excludefiletail_core [dict get $opts -exclude-filetails-core]
if {$opt_excludefiletail_core eq "\uFFFF"} {
set opt_excludefiletail_core [default_excludefiletail_core]
dict set opts -exclude-filetails-core $opt_excludefiletail_core
}
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
set opt_excludefiletail [dict get $opts -exclude-filetails]
#validate no path seps
foreach af $opt_excludefiletail {
if {[llength [file split $af]] > 1} {
error "punkcheck::install received invalid -exclude-filetails entry '$af'. -exclude-filetails entries are meant to match to a file name at any level so cannot contain path separators"
}
}
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
# Unified -exclude-paths initialization (replaces -exclude-dirsegments + -antiglob-paths)
# Precedence: -exclude-paths-core > -exclude-dirsegments-core (legacy) > default
set opt_excludepaths_core [dict get $opts -exclude-paths-core]
set legacy_excludedirseg_core [dict get $opts -exclude-dirsegments-core]
if {$opt_excludepaths_core ne "\uFFFF"} {
# User explicitly passed -exclude-paths-core → use as-is (path-glob form)
} elseif {$legacy_excludedirseg_core ne "\uFFFF"} {
# User explicitly passed legacy -exclude-dirsegments-core → convert to path-glob form
set opt_excludepaths_core [list]
foreach seg $legacy_excludedirseg_core {
lappend opt_excludepaths_core $seg
lappend opt_excludepaths_core **/$seg
}
dict set opts -exclude-paths-core $opt_excludepaths_core
} else {
# Neither passed → use new default
set opt_excludepaths_core [default_excludepaths_core]
dict set opts -exclude-paths-core $opt_excludepaths_core
}
set opt_excludepaths [dict get $opts -exclude-paths]
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
# Backward-compat: convert legacy -exclude-dirsegments to path-glob form and merge
# Each segment-glob S is expanded to {S **/S} to match at any directory level,
# preserving the former string-match-against-tail semantics.
set opt_excludedirseg_core $legacy_excludedirseg_core
if {$opt_excludedirseg_core eq "\uFFFF"} {
set opt_excludedirseg_core [default_excludedirseg_core]
dict set opts -exclude-dirsegments-core $opt_excludedirseg_core
}
set opt_excludedirseg [dict get $opts -exclude-dirsegments]
#validate no path seps
foreach ad $opt_excludedirseg {
if {[llength [file split $ad]] > 1} {
error "punkcheck::install received invalid -exclude-dirsegments entry '$ad'. -exclude-dirsegments entries are meant to match to a directory name at any level so cannot contain path separators"
}
}
#convert legacy dir-seg user globs (NOT core) to path-glob form and append to opt_excludepaths
foreach seg $opt_excludedirseg {
lappend opt_excludepaths $seg
lappend opt_excludepaths **/$seg
}
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
# Backward-compat: merge legacy -antiglob-paths into opt_excludepaths
set opt_antiglob_paths [dict get $opts -antiglob-paths] ;#todo - combine with config file in source tree .punkcheckpublish (?)
#antiglob_paths will usually contain file separators - and may contain glob patterns within each segment
foreach ap $opt_antiglob_paths {
lappend opt_excludepaths $ap
}
set antiglob_paths_matched [list]
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
set known_whats [list no-targets newer-targets older-targets all-targets installedsourcechanged-targets synced-targets]
set overwrite_what [string tolower [dict get $opts -overwrite]]; #accept any case for value to allow emphasis by caller e.g -overwrite NEWER-TARGETS
if {$overwrite_what ni $known_whats} {
error "punkcheck::install received unrecognised value for -overwrite. Received value '$overwrite_what' vs known values '$known_whats'"
}
if {$overwrite_what in [list newer-targets older-targets]} {
error "punkcheck::install newer-target, older-targets not implemented - sorry"
#TODO - check crossplatform availability of ctime (on windows it still seems to be creation time, but on bsd/linux it's last attribute mod time)
# external pkg? use twapi and ctime only on other platforms?
}
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
set opt_source_checksum [dict get $opts -source-checksum]
if {[string is boolean $opt_source_checksum]} {
if {$opt_source_checksum} {
set opt_source_checksum "comparestore"
} else {
set opt_source_checksum 0
}
dict set opts -source-checksum $opt_source_checksum
}
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
set opt_punkcheck_folder [dict get $opts -punkcheck-folder]
if {$opt_punkcheck_folder eq "target"} {
set punkcheck_folder $tgtdir
} elseif {$opt_punkcheck_folder eq "source"} {
set punkcheck_folder $srcdir
} elseif {$opt_punkcheck_folder eq "project"} {
set sourceprojectinfo [punk::repo::find_repos $srcdir]
set targetprojectinfo [punk::repo::find_repos $tgtdir]
set srcproj [lindex [dict get $sourceprojectinfo project] 0]
set tgtproj [lindex [dict get $targetprojectinfo project] 0]
if {$srcproj eq $tgtproj} {
set punkcheck_folder $tgtproj
} else {
error "copy_files_from_source_to_target error: Unable to find common project dir for source and target folder - use absolutepath for -punkcheck-folder if source and target are not within same project"
}
} else {
set punkcheck_folder $opt_punkcheck_folder
}
if {$punkcheck_folder ne ""} {
if {[file pathtype $punkcheck_folder] ne "absolute"} {
error "copy_files_from_source_to_target error: -punkcheck-folder '$punkcheck_folder' must be an absolute path, or one of: target|source|project"
}
if {![file isdirectory $punkcheck_folder]} {
error "copy_files_from_source_to_target error: -punkcheck-folder '$punkcheck_folder' not found"
}
} else {
#review - leave empty? use pwd?
}
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
#-punkcheck-records is vestigial as of G-094 (the working recordset lives in the installtrack
#instance) - the option remains accepted for call compatibility. Pre-G-094 behaviour also
#discarded the passed value at depth 0 (replaced from the file via start_installer_event).
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
set opt_installer [dict get $opts -installer]
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
set opt_punkcheck_eventid [dict get $opts -punkcheck-eventid]
set opt_punkcheck_eventobj [dict get $opts -punkcheck-eventobj]
set punkcheck_file [file join $punkcheck_folder/.punkcheck]
if {$CALLDEPTH == 0} {
if {$punkcheck_folder eq ""} {
#pre-G-094 code silently skipped event setup for an empty -punkcheck-folder and then failed
#during path relativization - fail early and clearly instead. (folder resolution above yields
#"" only when the caller explicitly passes -punkcheck-folder "")
error "punkcheck::install requires a resolvable -punkcheck-folder (target|source|project|<absolutepath>) - received an empty value"
}
set config $opts
dict unset config -call-depth-internal
dict unset config -max-depth
dict unset config -subdirlist
dict unset config -progresschannel
dict unset config -punkcheck-eventobj
tcl::dict::for {k v} $config {
if {$v eq "\uFFFF"} {
dict unset config $k
}
}
#G-094: batch install is a tree-walking consumer of the OO layer in deferred persistence mode.
#The installtrack working recordset accumulates finalized records in memory; one flush at the
#end of the walk persists them (event-scoped save), preserving the original batch write pattern.
set punkcheck_installer [punkcheck::installtrack new $opt_installer $punkcheck_file]
$punkcheck_installer set_source_target $srcdir $tgtdir
set punkcheck_eventobj [$punkcheck_installer start_event $config -persistence deferred]
set punkcheck_eventid [$punkcheck_eventobj get_id]
} else {
set punkcheck_eventobj $opt_punkcheck_eventobj
set punkcheck_installer [$punkcheck_eventobj get_installer]
set punkcheck_eventid $opt_punkcheck_eventid
}
if {[llength $subdirlist] == 0} {
set current_source_dir $srcdir
set current_target_dir $tgtdir
} else {
set current_source_dir $srcdir/[file join {*}$subdirlist]
set current_target_dir $tgtdir/[file join {*}$subdirlist]
}
set relative_target_dir [lib::path_relative $tgtdir $current_target_dir]
if {$relative_target_dir eq "."} {
set relative_target_dir ""
}
set relative_source_dir [lib::path_relative $srcdir $current_source_dir]
if {$relative_source_dir eq "."} {
set relative_source_dir ""
}
set target_relative_to_punkcheck_dir [lib::path_relative $punkcheck_folder $current_target_dir]
if {$target_relative_to_punkcheck_dir eq "."} {
set target_relative_to_punkcheck_dir ""
}
foreach unpub $opt_antiglob_paths {
#puts "testing folder - globmatchpath $unpub $relative_source_dir"
if {[punk::path::globmatchpath $unpub $relative_source_dir]} {
lappend antiglob_paths_matched $current_source_dir
set punkcheck_records [$punkcheck_installer get_recordlist]
if {$CALLDEPTH == 0} {
$punkcheck_eventobj end
$punkcheck_eventobj destroy
$punkcheck_installer destroy
}
return [list files_copied {} files_skipped {} sources_unchanged {} punkcheck_records $punkcheck_records antiglob_paths_matched $antiglob_paths_matched srcdir $srcdir tgtdir $tgtdir punkcheck_folder $punkcheck_folder]
}
}
if {![file exists $current_source_dir]} {
error "punkcheck::install current source dir:'$current_source_dir' doesn't exist (srcdir:$srcdir tgtdir:$tgtdir args:'$args')"
}
set files_copied [list]
set files_skipped [list]
set sources_unchanged [list]
set candidate_list [glob -nocomplain -dir $current_source_dir -type f -tail -- $fileglob]
set hidden_candidate_list [glob -nocomplain -dir $current_source_dir -types {hidden f} -tail -- $fileglob]
foreach h $hidden_candidate_list {
if {$h ni $candidate_list} {
lappend candidate_list $h
}
}
set match_list [list]
foreach m $candidate_list {
set suppress 0
foreach anti [concat $opt_excludefiletail_core $opt_excludefiletail] {
if {[string match $anti $m]} {
#puts stderr "anti: $anti vs m:$m"
set suppress 1
break
}
}
if {$suppress == 0} {
lappend match_list $m
}
}
#sample .punkcheck file record (raw form) to make the code clearer
#punk::tdl converts to dict form e.g: tag FILEINFO -targets filename body sublist
#Valid installrecord types are INSTALL-RECORD SKIPPED INSTALL-INPROGRESS, MODIFY-RECORD MODIFY-INPROGRESS DELETE-RECORD DELETE-INPROGRESS
#
#FILEINFO -targets jjjetc-0.1.0.tm -keep_installrecords 2 -keep_skipped 1 -keep_inprogress 2 {
# INSTALL-RECORD -tsiso 2023-09-20T07:30:30 -ts 1695159030266610 -installer punk::mix::cli::build_modules_from_source_to_base -metadata_us 18426 -ts_start_transfer 1695159030285036 -transfer_us 10194 -elapsed_us 28620 {
# SOURCE -type file -path ../src/modules/jjjetc-buildversion.txt -cksum c7c71839c36b3d21c8370fed106192fcd659eca9 -cksum_all_opts {-cksum_content 1 -cksum_meta 0 -cksum_acls 0 -cksum_usetar 0 -cksum_algorithm sha1} -changed 1 -metadata_us 3423
# SOURCE -type file -path ../src/modules/jjjetc-0.6.0.tm -cksum b646fc2ee88cbd068d2e946fe929b7aea96bd39d -cksum_all_opts {-cksum_content 1 -cksum_meta 0 -cksum_acls 0 -cksum_usetar 0 -cksum_algorithm sha1} -changed 1 -metadata_us 3413
# }
# INSTALL-SKIPPED -tsiso 2023-09-20T08:14:26 -ts 1695161666087880 -installer punk::mix::cli::build_modules_from_source_to_base -elapsed_us 18914 {
# SOURCE -type file -path ../src/modules/jjjetc-buildversion.txt -cksum c7c71839c36b3d21c8370fed106192fcd659eca9 -cksum_all_opts {-cksum_content 1 -cksum_meta 0 -cksum_acls 0 -cksum_usetar 0 -cksum_algorithm sha1} -changed 0 -metadata_us 3435
# SOURCE -type file -path ../src/modules/jjjetc-0.6.0.tm -cksum b646fc2ee88cbd068d2e946fe929b7aea96bd39d -cksum_all_opts {-cksum_content 1 -cksum_meta 0 -cksum_acls 0 -cksum_usetar 0 -cksum_algorithm sha1} -changed 0 -metadata_us 3338
# }
#}
if {[llength $match_list]} {
#example - target dir has a file where there is a directory at the source
if {[file exists $current_target_dir] && ([file type $current_target_dir] ni [list directory])} {
error "punkcheck::install target subfolder $current_target_dir exists but is not of type 'directory'. Type current target folder: [file type $current_target_dir]"
}
}
#proc get_relativecksum_from_base_and_fullpath {base fullpath args}
#puts stdout "Current target dir: $current_target_dir"
set last_depth ""
foreach m $match_list {
set relative_target_path [file join $relative_target_dir $m]
set relative_source_path [file join $relative_source_dir $m]
set punkcheck_target_relpath [file join $target_relative_to_punkcheck_dir $m]
set is_antipath 0
foreach pathglob [concat $opt_excludepaths_core $opt_excludepaths] {
#puts "testing file - globmatchpath $pathglob vs $relative_source_path"
if {[punk::path::globmatchpath $pathglob $relative_source_path]} {
lappend antiglob_paths_matched $current_source_dir
set is_antipath 1
break
}
}
if {$is_antipath} {
continue
}
#puts stdout " rel_target: $punkcheck_target_relpath"
if {$opt_progresschannel ne ""} {
#pre-G-094 output parity: announce first-seen targets when progress output is active
set fetch_filerec_result [punkcheck::recordlist::get_file_record $punkcheck_target_relpath [$punkcheck_installer get_recordlist]]
if {[dict get $fetch_filerec_result position] == -1} {
puts stdout "\nNO existing record for $punkcheck_target_relpath"
}
}
#G-094: per-target record lifecycle through the OO layer (deferred persistence - no per-file saves,
#finalized records accumulate in the installtrack working recordset until the end-of-walk flush)
$punkcheck_eventobj targetset_init INSTALL $punkcheck_target_relpath
if {$opt_progresschannel ne ""} {
if {$last_depth ne $CALLDEPTH} {
if {$CALLDEPTH <=1} {
puts -nonewline $opt_progresschannel \n[string repeat " " $CALLDEPTH][file tail $relative_source_dir]
}
flush $opt_progresschannel
##set last_depth $CALLDEPTH ;# done down below
}
}
set relative_source_path [lib::path_relative $punkcheck_folder $current_source_dir/$m]
#puts stdout " rel_source: $relative_source_path"
#if {[file pathtype $relative_source_path] ne "relative"} {
#REVIEW
#different volume or root
#}
#targetset_addsource hits the filesystem for the sourcepath - gets checksums/timestamps
#depending on config - and returns the updated fileset record (no save in deferred mode)
set ts1 [clock milliseconds]
set filerec [$punkcheck_eventobj targetset_addsource $relative_source_path]
set ts2 [clock milliseconds]
set diff [expr {$ts2 - $ts1}]
if {$diff > 100} {
#todo -errorchannel
set errprefix ">>> punkcheck:"
puts stderr "\n$errprefix performance warning: fetch_metadata for $m took $diff ms."
set lb [lindex [dict get $filerec body] end]
#puts stderr "$errprefix filerec last body record:$lb"
set records [dict get $lb body]
set lr [lindex $records end]
set alg [dict get $lr -cksum_all_opts -cksum_algorithm]
if {$alg eq "sha1"} {
puts stderr "$errprefix cksum_algorithm: sha1 (accelerators: [::sha1::Implementations])"
puts stderr "$errprefix sha1 from: [package ifneeded sha1 [package present sha1]]"
} else {
puts stderr "$errprefix cksum_algorithm: $alg"
}
}
#changeinfo comes from last record in body - which is the record we are working on and so will always exist
set changeinfo [$punkcheck_eventobj targetset_source_changes]
set changed [dict get $changeinfo changed]
set unchanged [dict get $changeinfo unchanged]
if {[llength $unchanged]} {
lappend sources_unchanged $current_source_dir/$m
}
set is_skip 0
set is_new 0
if {$overwrite_what eq "all-targets"} {
$punkcheck_eventobj targetset_started
file mkdir $current_target_dir
#--------------------------------------------
#sometimes we get the error: 'error copying "file1" to "file2": invalid argument'
#--------------------------------------------
puts stderr "punkcheck: about to: file copy -force $current_source_dir/$m $current_target_dir"
file copy -force $current_source_dir/$m $current_target_dir
lappend files_copied $current_source_dir/$m
} else {
if {![file exists $current_target_dir/$m]} {
#puts stderr "punkcheck: first copy to $current_target_dir/$m "
$punkcheck_eventobj targetset_started
file mkdir $current_target_dir
puts stderr "punkcheck: about to: file copy $current_source_dir/$m $current_target_dir"
file copy $current_source_dir/$m $current_target_dir
lappend files_copied $current_source_dir/$m
set is_new 1
} else {
switch -- $overwrite_what {
installedsourcechanged-targets {
if {[llength $changed]} {
#An unrecorded installation is considered a source change (from unknown/unrecorded source to recorded)
$punkcheck_eventobj targetset_started
puts -nonewline stderr "punkcheck: about to: file copy -force $current_source_dir/$m $current_target_dir"
set ts1 [clock milliseconds]
file mkdir $current_target_dir
file copy -force $current_source_dir/$m $current_target_dir
set ts2 [clock milliseconds]
puts stderr " (copy time [expr {$ts2 - $ts1}] ms)"
lappend files_copied $current_source_dir/$m
} else {
set is_skip 1
lappend files_skipped $current_source_dir/$m
}
}
synced-targets {
#disallow overwriting of target that has been modified by some other mechanism
#review
if {[llength $changed]} {
#only overwrite if the target file checksum equals the last installed checksum (ie target is in sync with previous source-set and so hasn't been customized)
set existing_tgt_cksum_info [punk::mix::base::lib::cksum_path $current_target_dir/$m]
set is_target_unmodified_since_install 0
set target_cksum_compare "unknown"
set latest_install_record [punkcheck::recordlist::file_record_latest_installrecord $filerec] ;#may be no such record - in which case we get an empty list
if {[dict exists $latest_install_record -targets_cksums]} {
set last_install_cksum [dict get $latest_install_record -targets_cksums] ;#in this case we know there is only one as 'install' always uses targetset size of 1. (FILEINFO record per file in source folder)
if {[dict get $existing_tgt_cksum_info cksum] eq $last_install_cksum} {
set is_target_unmodified_since_install 1
set target_cksum_compare "match"
} else {
set target_cksum_compare "nomatch"
}
} else {
set target_cksum_compare "norecord"
}
if {$is_target_unmodified_since_install} {
$punkcheck_eventobj targetset_started
file mkdir $current_target_dir
puts stderr "punkcheck: synced-targets about to: file copy -force $current_source_dir/$m $current_target_dir"
file copy -force $current_source_dir/$m $current_target_dir
lappend files_copied $current_source_dir/$m
} else {
#either cksum is different or we were unable to verify the record. Either way we can't know if the target is in sync so we must skip it
set is_skip 1
puts stderr "Skipping file copy $m target $current_target_dir/$m - require synced_target to overwrite - current target cksum compared to previous install: $target_cksum_compare"
lappend files_skipped $current_source_dir/$m
}
} else {
set is_skip 1
lappend files_skipped $current_source_dir/$m
}
}
default {
set is_skip 1
puts stderr "Skipping file copy $m target $current_target_dir/$m already exists (use -overwrite all-targets to overwrite)"
#TODO! implement newer-targets older-targets? (note ctimes/mtimes are unreliable - may not be worth implementing)
lappend files_skipped $current_source_dir/$m
}
}
}
}
#target dir was created as necessary if files matched above
#now ensure target dir exists if -createempty true
if {$opt_createempty && ![file exists $current_target_dir]} {
file mkdir $current_target_dir
}
#finalize the record: targetset_end sets the tag, -elapsed_us, and computes/stores post-install
#target cksums (-targets_cksums/-cksum_all_opts/-cksum_us) for successful installs, then merges
#the finalized record into the installtrack working recordset (in-memory - deferred mode)
if {$is_skip} {
$punkcheck_eventobj targetset_end SKIPPED
} else {
$punkcheck_eventobj targetset_end OK
}
#------------------------------------------------------------
if {$is_skip} {
set mark .
} else {
if {$is_new} {
set mark \x1b\[32\;1mN\x1b\[m
} else {
#updated
set mark \x1b\[32\;1mU\x1b\[m
}
}
if {$opt_progresschannel ne ""} {
if {$last_depth ne $CALLDEPTH} {
puts -nonewline $opt_progresschannel \n[string repeat " " $CALLDEPTH]$mark
flush $opt_progresschannel
set last_depth $CALLDEPTH
} else {
puts -nonewline $opt_progresschannel $mark
}
}
#------------------------------------------------------------
}
if {$max_depth != -1 && $CALLDEPTH >= $max_depth} {
#don't process any more subdirs
#sometimes deliberately called with max_depth 1 - so don't warn here. review
#puts stderr "punkcheck::install warning - reached max_depth $max_depth"
set subdirs [list]
} else {
set subdirs [glob -nocomplain -dir $current_source_dir -type d -tail *]
set hiddensubdirs [glob -nocomplain -dir $current_source_dir -type {hidden d} -tail *]
foreach h $hiddensubdirs {
switch -- $h {
"." - ".." {
continue
}
default {
if {$h ni $subdirs} {
lappend subdirs $h
}
}
}
}
}
#puts stderr "subdirs: $subdirs"
foreach d $subdirs {
set relative_source_path [file join $relative_source_dir $d]
set skipd 0
foreach pathglob [concat $opt_excludepaths_core $opt_excludepaths] {
#puts "testing folder - globmatchpath $pathglob vs $relative_source_path"
if {[punk::path::globmatchpath $pathglob $relative_source_path]} {
lappend antiglob_paths_matched [file join $current_source_dir $d]
#puts stdout "SKIPPING FOLDER $relative_source_path due to exclude-path-match: $pathglob "
set skipd 1
break
}
}
if {$skipd} {
continue
}
#if {![file exists $current_target_dir/$d]} {
# file mkdir $current_target_dir/$d
#}
set sub_opts [list {*}{
} -call-depth-internal [expr {$CALLDEPTH + 1}] {*}{
} -subdirlist [list {*}$subdirlist $d] {*}{
} -punkcheck-folder $punkcheck_folder {*}{
} -punkcheck-eventid $punkcheck_eventid {*}{
} -punkcheck-eventobj $punkcheck_eventobj {*}{
} -progresschannel $opt_progresschannel {*}{
}
]
set sub_opts [dict merge $opts $sub_opts]
set sub_result [punkcheck::install $srcdir $tgtdir {*}$sub_opts]
lappend files_copied {*}[dict get $sub_result files_copied]
lappend files_skipped {*}[dict get $sub_result files_skipped]
lappend sources_unchanged {*}[dict get $sub_result sources_unchanged]
lappend antiglob_paths_matched {*}[dict get $sub_result antiglob_paths_matched]
#the evolving recordset is shared state in the installtrack instance - nothing to carry back
}
if {$CALLDEPTH == 0} {
#event-scoped deferred flush: one save persists the whole walk's finalized records.
#compare-only runs (-source-checksum compare) never flush - the in-memory updates are
#deliberately discarded, matching the pre-G-094 batch behaviour (the start_event save
#already registered the event itself in both cases).
if {[string match *store* $opt_source_checksum] && ([llength $files_copied] || [llength $files_skipped])} {
set saveresult [$punkcheck_installer flush "install $srcdir to $tgtdir"]
puts stdout "\npunkcheck::install [dict get $saveresult recordcount] records saved as [dict get $saveresult linecount] lines to $punkcheck_file copied: [llength $files_copied] skipped: [llength $files_skipped]"
}
set punkcheck_records [$punkcheck_installer get_recordlist]
$punkcheck_eventobj end
$punkcheck_eventobj destroy
$punkcheck_installer destroy
} else {
set punkcheck_records [$punkcheck_installer get_recordlist]
}
return [list files_copied $files_copied files_skipped $files_skipped sources_unchanged $sources_unchanged antiglob_paths_matched $antiglob_paths_matched punkcheck_records $punkcheck_records punkcheck_folder $punkcheck_folder srcdir $srcdir tgtdir $tgtdir]
}
lappend PUNKARGS [list {
@id -id ::punkcheck::summarize_install_resultdict
@cmd -name punkcheck::summarize_install_resultdict -help\
"Emits a string summarizing a punkcheck resultdict, showing
how many items were copied, and the source, target locations"
@opts
-title -type string -default ""
-forcecolour -type boolean -default 0 -help\
"When true, passes the forcecolour tag to punk::ansi functions.
This enables ANSI sgr colours even when colour
is off. (ignoring env(NO_COLOR))
To disable colour - ensure the NO_COLOR env var is set,
or use:
namespace eval ::punk::console {variable colour_disabled 1}"
@values -min 1 -max 1
resultdict -type dict
}]
proc summarize_install_resultdict {args} {
set argd [punk::args::parse $args withid ::punkcheck::summarize_install_resultdict]
lassign [dict values $argd] leaders opts values received
set title [dict get $opts -title]
set forcecolour [dict get $opts -forcecolour]
set resultdict [dict get $values resultdict]
set has_ansi [expr {![catch {package require punk::ansi}]}]
if {$has_ansi} {
if {$forcecolour} {
set fc "forcecolour"
} else {
set fc ""
}
set R [punk::ansi::a] ;#reset
set LINE_COLOUR [punk::ansi::a+ {*}$forcecolour bold cyan]
set LOW_COLOUR [punk::ansi::a+ {*}$forcecolour bold green]
set HIGH_COLOUR [punk::ansi::a+ {*}$forcecolour bold yellow]
} else {
set R ""
set LINE_COLOUR ""
set LOW_COLOUR ""
set HIGH_COLOUR ""
}
set msg ""
if {[dict size $resultdict]} {
set copied [dict get $resultdict files_copied]
if {[llength $copied] == 0} {
set HIGHLIGHT $LOW_COLOUR
} else {
set HIGHLIGHT $HIGH_COLOUR
}
set ruler $LINE_COLOUR[string repeat - 78]$R
if {$title ne ""} {
append msg $ruler \n
append msg $title \n
}
append msg $ruler \n
#append msg "[dict keys $resultdict]" \n
set tgtdir [dict get $resultdict tgtdir]
set checkfolder [dict get $resultdict punkcheck_folder]
append msg "${HIGHLIGHT}Copied [llength $copied] files from [dict get $resultdict srcdir] to [dict get $resultdict tgtdir]$R" \n
foreach f $copied {
append msg "COPIED [punkcheck::lib::path_relative $checkfolder $f]" \n
append msg " TO $tgtdir" \n
}
append msg "[llength [dict get $resultdict sources_unchanged]] unchanged source files" \n
append msg "[llength [dict get $resultdict files_skipped]] skipped files" \n
append msg $ruler \n
}
return $msg
}
namespace eval recordlist {
set pkg punkcheck
namespace path ::punkcheck
proc records_as_target_dict {record_list} {
set result [dict create]
foreach rec $record_list {
if {[dict get $rec tag] eq "FILEINFO"} {
set tgtlist [dict get $rec -targets]
if {[dict exists $result $tgtlist]} {
#todo - warn - duplicate record for same targetlist - shouldn't happen as we should be using get_file_record to find existing records
error "punkcheck::recordlist::records_as_target_dict - multiple records with same targetlist '$tgtlist'"
}
dict set result $tgtlist $rec
}
}
return $result
}
#will only match if same base was used.. and same targetlist
proc get_file_record {targetlist record_list} {
set posn 0
set found_posn -1
set record ""
foreach rec $record_list {
if {[dict get $rec tag] eq "FILEINFO"} {
if {[dict get $rec -targets] eq $targetlist} {
set found_posn $posn
set record $rec
break
}
}
incr posn
}
return [list position $found_posn record $record]
}
proc file_install_record_source_changes {install_record} {
#reject INSTALLFAILED items ?
switch -- [dict get $install_record tag] {
"QUERY-INPROGRESS" -
"INSTALL-RECORD" -
"INSTALL-SKIPPED" -
"INSTALL-INPROGRESS" -
"MODIFY-INPROGRESS" -
"MODIFY-RECORD" -
"MODIFY-SKIPPED" -
"VIRTUAL-INPROGRESS" -
"VIRTUAL-RECORD" -
"VIRTUAL-SKIPPED" -
"DELETE-RECORD" -
"DELETE-INPROGRESS" -
"DELETE-SKIPPED" {
}
default {
error "file_install_record_source_changes bad install record: tag '[dict get $install_record tag]' not INSTALL-RECORD|SKIPPED|INSTALL-INPROGRESS|MODIFY-RECORD|MODIFY-INPROGRESS|VIRTUAL-RECORD|VIRTUAL-INPROGRESS|DELETE-RECORD|DELETE-INPROGRESS"
}
}
set source_list [dict_getwithdefault $install_record body [list]]
set changed [list]
set unchanged [list]
foreach src $source_list {
if {[dict exists $src -changed]} {
if {[dict get $src -changed] !=0} {
lappend changed [dict get $src -path]
} else {
lappend unchanged [dict get $src -path]
}
} else {
lappend changed [dict get $src -path]
}
}
return [dict create changed $changed unchanged $unchanged]
}
#assume only one for name - use first encountered?
proc get_installer_record {name record_list} {
set posn 0
set found_posns [list]
set record ""
#puts ">>>> checking [llength $record_list] punkcheck records"
foreach rec $record_list {
if {[dict get $rec tag] eq "INSTALLER"} {
if {[dict get $rec -name] eq $name} {
set found_posn $posn
set record $rec
lappend found_posns $posn
}
}
incr posn
}
if {[llength $found_posns] > 1} {
error "punkcheck::recordlist::get_installer_record - multiple installer records with name '$name' found at positions $found_posns"
} elseif {[llength $found_posns] == 0} {
return [list position -1 record ""]
} else {
#single record found
return [list position [lindex $found_posn 0] record $record]
}
}
proc new_installer_record {name args} {
if {[llength $args] %2 !=0} {
error "punkcheck new_installer_record args must be name-value pairs"
}
set ts [clock microseconds]
set seconds [expr {$ts / 1000000}]
set tsiso [clock format $seconds -format "%Y-%m-%dT%H:%M:%S"]
#put -tsiso first so it lines up with -tsiso in event records
set defaults [list {*}{
} -tsiso $tsiso {*}{
} -ts $ts {*}{
} -name $name {*}{
} -keep_events 5 {*}{
}
]
set opts [dict merge $defaults $args]
#set this_installer_record_list [punk::tdl::prettyparse [list INSTALLER name $opt_installer ts $ts tsiso $tsiso keep_events 5 {}]]
#set this_installer_record [lindex $this_installer_record_list 0]
set record [dict create tag INSTALLER {*}$opts body {}]
return $record
}
proc new_installer_event_record {type args} {
if {[llength $args] %2 !=0} {
error "punkcheck new_installer_event_record args must be name-value pairs"
}
set ts [clock microseconds]
set seconds [expr {$ts / 1000000}]
set tsiso [clock format $seconds -format "%Y-%m-%dT%H:%M:%S"]
set defaults [list {*}{
} -tsiso $tsiso {*}{
} -ts $ts {*}{
} -type $type {*}{
}
]
set opts [dict merge $defaults $args]
set record [dict create tag EVENT {*}$opts]
}
#need to scan entire set if filerecords to check if event is still referenced
proc installer_record_pruneevents {installer_record record_list} {
set keep 5
if {[dict exists $installer_record -keep_events]} {
set keep [dict get $installer_record -keep_events]
}
if {[dict exists $installer_record body]} {
set body_items [dict get $installer_record body]
} else {
set body_items [list]
}
set kept_body_items [list]
set kcount 0
foreach item [lreverse $body_items] {
if {[dict get $item tag] eq "EVENT"} {
incr kcount
if {$keep < 0 || $kcount <= $keep} {
lappend kept_body_items $item
} else {
set eventid ""
if {[dict exists $item -id]} {
set eventid [dict get $item -id]
}
if {$eventid ne "" && $eventid ne "unspecified"} {
#keep if referenced, discard if not, or if eventid empty/unspecified
set is_referenced 0
foreach rec $record_list {
if {[dict get $rec tag] eq "FILEINFO"} {
if {[dict exists $rec body]} {
foreach install [dict get $rec body] {
if {[dict exists $install -eventid] && [dict get $install -eventid] eq $eventid} {
set is_referenced 1
break
}
}
}
}
if {$is_referenced} {
break
}
}
if {$is_referenced} {
lappend kept_body_items $item
}
}
}
} else {
lappend kept_body_items $item
}
}
set kept_body_items [lreverse $kept_body_items]
dict set installer_record body $kept_body_items
return $installer_record
}
proc installer_record_add_event {installer_record event} {
if {[dict get $installer_record tag] ne "INSTALLER"} {
error "installer_record_add_event bad installer record: tag not INSTALLER"
}
if {[dict get $event tag] ne "EVENT"} {
error "installer_record_add_event bad event record: tag not EVENT"
}
if {[dict exists $installer_record body]} {
set body_items [dict get $installer_record body]
} else {
set body_items [list]
}
lappend body_items $event
dict set installer_record body $body_items
return $installer_record
}
proc file_record_latest_installrecord {file_record} {
tailcall file_record_latest_operationrecord INSTALL $file_record
}
proc file_record_latest_operationrecord {operation file_record} {
set operation [string toupper $operation]
if {[dict get $file_record tag] ne "FILEINFO"} {
error "file_record_latest_operationrecord bad file_record: tag not FILEINFO"
}
if {![dict exists $file_record body]} {
return [list]
}
set body_items [dict get $file_record body]
foreach item [lreverse $body_items] {
if {[dict get $item tag] eq "$operation-RECORD"} {
return $item
}
}
return [list]
}
proc file_record_set_defaults {file_record} {
if {[dict get $file_record tag] ne "FILEINFO"} {
error "file_record_set_defaults bad file_record: tag not FILEINFO"
}
set defaults [list -keep_installrecords 3 -keep_skipped 1 -keep_inprogress 2]
foreach {k v} $defaults {
if {![dict exists $file_record $k]} {
dict set file_record $k $v
}
}
return $file_record
}
#negative keep_ value will keep all
proc file_record_prune {file_record} {
if {[dict get $file_record tag] ne "FILEINFO"} {
error "file_record_prune bad file_record: tag not FILEINFO"
}
set file_record [file_record_set_defaults $file_record]
set kmap [list -keep_installrecords *-RECORD -keep_skipped *-SKIPPED -keep_inprogress *-INPROGRESS]
foreach {key rtype} $kmap {
set keep [dict get $file_record $key]
if {[dict exists $file_record body]} {
set body_items [dict get $file_record body]
} else {
set body_items [list]
}
set kept_body_items [list]
set kcount 0
foreach item [lreverse $body_items] {
if {[string match $rtype [dict get $item tag]]} {
incr kcount
if {$keep < 0 || $kcount <= $keep} {
lappend kept_body_items $item
}
} else {
lappend kept_body_items $item
}
}
set kept_body_items [lreverse $kept_body_items]
dict set file_record body $kept_body_items
}
return $file_record
}
#extract new or existing filerecord for path given
#locking/concurrency: G-095 (this is a pure record transform - no file access; the isnew key
#tells the caller whether a record existed, callers own any reporting)
proc extract_or_create_fileset_record {relative_target_paths recordset} {
set fetch_record_result [punkcheck::recordlist::get_file_record $relative_target_paths $recordset]
set existing_posn [dict get $fetch_record_result position]
if {$existing_posn == -1} {
set isnew 1
set fileset_record [dict create tag FILEINFO -targets $relative_target_paths body {}]
} else {
#set recordset [lreplace $recordset[unset recordset] $existing_posn $existing_posn]
#set recordset [lreplace $recordset[set recordset {}] $existing_posn $existing_posn]
ledit recordset $existing_posn $existing_posn
set isnew 0
set fileset_record [dict get $fetch_record_result record]
}
return [list record $fileset_record recordset $recordset isnew $isnew oldposition $existing_posn]
}
}
}
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punkcheck ::punkcheck::lock
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Ready
package provide punkcheck [namespace eval punkcheck {
set pkg punkcheck
variable version
set version 0.6.0
}]
return