← Files NightshiftARCHIVED FILE

runtime/windows/archive-receipts.ps1

18.7 KB · Oct 2, 2026 · 00:30 UTC

↓ Download file

param(
    [string]$Project = [Environment]::CurrentDirectory,
    [string]$Date = '',
    [string[]]$Retire = @()
)

# archive-receipts.ps1 - file a shift into its archive folder, laid out the way it was live. The
# native twin of runtime/archive-receipts.sh, with the same records, the same paths and the same
# rules: only what is closed is filed, once, and the live side keeps only what is still open.

Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'

$pluginRoot = Resolve-Path (Join-Path $PSScriptRoot '../..')
Import-Module (Join-Path $pluginRoot 'lib/Nightshift.psm1') -Force -DisableNameChecking

function Write-NSArchiveReceiptsError {
    param([string]$Message)
    [Console]::Error.WriteLine($Message)
}

try {
    $hostPath = Resolve-NSCanonicalPath $Project
}
catch {
    Write-NSArchiveReceiptsError "archive-receipts: cannot cd to $Project"
    exit 1
}

try {
    $workspace = Resolve-NSWorkspaceRoot $hostPath
}
catch {
    Write-NSArchiveReceiptsError 'archive-receipts: invalid .nightshift-link - Nightshift will not guess a workspace'
    exit 2
}

$kind = Get-NSStateKind $workspace
if ($kind -in @('malformed', 'future')) {
    Write-NSArchiveReceiptsError ("archive-receipts: {0}" -f (Get-NSStateRefuseMessage $kind))
    exit 2
}
if ($kind -eq 'absent') {
    Write-NSArchiveReceiptsError "archive-receipts: no .nightshift/ at $workspace"
    exit 2
}

$ns = Join-Path $workspace '.nightshift'
if ([string]::IsNullOrWhiteSpace($Date)) {
    $Date = Get-Date -Format 'yyyy-MM-dd'
}
if ($Date -notmatch '^\d{4}-\d{2}-\d{2}$') {
    Write-NSArchiveReceiptsError 'archive-receipts: -Date must be YYYY-MM-DD'
    exit 1
}

# Invoked with -File, PowerShell hands an array parameter through as one string, so the list is
# split here and both invocation styles name the same records.
$Retire = @($Retire | ForEach-Object { $_ -split ',' } | Where-Object { $_ -cne '' })
foreach ($name in $Retire) {
    if ([string]::IsNullOrEmpty($name) -or $name -cmatch '[\\/]' -or $name.StartsWith('.', [StringComparison]::Ordinal)) {
        Write-NSArchiveReceiptsError "archive-receipts: -Retire takes a record name, not a path: $name"
        exit 1
    }
}

# A closed record leaves live storage only when a shift has actually ended and the archived copy
# has been read back and matches. While a shift is armed nothing is removed at all.
$armed = Test-Path -LiteralPath (Get-NSLayoutPath $ns 'armed')
$endedMarker = Get-NSLayoutPath $ns 'ended'
$ended = (Test-Path -LiteralPath $endedMarker -PathType Leaf) -and
    -not ((Get-Item -LiteralPath $endedMarker -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)
$rotate = (-not $armed) -and $ended
if (-not $rotate -and $Retire.Count -gt 0) {
    if ($armed) {
        Write-NSArchiveReceiptsError 'archive-receipts: refuse to retire anything while the shift is armed'
    }
    else {
        Write-NSArchiveReceiptsError 'archive-receipts: refuse to retire anything before the shift has ended'
    }
    exit 2
}

function Get-NSArchiveRel {
    param([string]$Key, [string]$Instance = '')
    return (Get-NSLayoutRelativePath $ns $Key $Instance)
}

function Join-NSArchiveRel {
    param([string]$Base, [string]$Rel)
    return (Join-NSPath $Base ($Rel.Replace('/', [IO.Path]::DirectorySeparatorChar)))
}

$src = Get-NSReceiptsDir $workspace
# Whose records these are. Once the shift has ended, the ending marker says which shift that was,
# even when a policy for the next one is already live; before then the live policy answers.
$policyId = ''
$policyState = Get-NSShiftPolicyState $workspace
if ($policyState['state'] -ceq 'valid') { $policyId = [string]$policyState['policy']['shiftId'] }
$endedId = [string](Get-NSEndedField $workspace 'shiftId')
$shiftId = $policyId
if ($rotate -and -not [string]::IsNullOrEmpty($endedId)) {
    $shiftId = $endedId
}
elseif ([string]::IsNullOrEmpty($shiftId) -or $shiftId -ceq 'unknown') {
    if (-not [string]::IsNullOrEmpty($endedId)) { $shiftId = $endedId }
}
$group = $null
try { $group = Get-NSArchiveGroup -Workspace $workspace -Date $Date -ShiftId $shiftId } catch { $group = $null }
if ([string]::IsNullOrEmpty($group)) {
    Write-NSArchiveReceiptsError 'archive-receipts: archive.root must name a directory inside .nightshift/ - an absolute path, a path with .., or a symlink is not supported'
    exit 2
}
$receiptsRel = Get-NSArchiveRel 'receipts'
$dest = Join-NSArchiveRel $group $receiptsRel
if ((Test-Path -LiteralPath $src) -and (Test-NSReparsePoint $src)) {
    Write-NSArchiveReceiptsError 'archive-receipts: refuse to write through a symlink receipts path'
    exit 2
}
if ((Test-Path -LiteralPath $src) -and -not (Test-Path -LiteralPath $src -PathType Container)) {
    Write-NSArchiveReceiptsError 'archive-receipts: receipts path is not a directory'
    exit 2
}
foreach ($p in @((Get-NSArchiveRoot $workspace), $group, $dest)) {
    if (Test-Path -LiteralPath $p) {
        $item = Get-Item -LiteralPath $p -Force
        if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
            Write-NSArchiveReceiptsError 'archive-receipts: refuse to write through a symlink archive path'
            exit 2
        }
        if (-not (Test-Path -LiteralPath $p -PathType Container)) {
            Write-NSArchiveReceiptsError 'archive-receipts: refuse to write through a non-directory archive path'
            exit 2
        }
    }
}

$utf8 = New-Object Text.UTF8Encoding($false)
$script:copied = 0
$script:removed = 0
$kept = New-Object Collections.Generic.List[string]
$filed = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal)
$filedLines = New-Object Collections.Generic.List[string]
$script:tickedNames = @(Get-NSTickedReceiptNames $workspace)
# The pages this folder already held before this run. They were repointed when they were filed.
$preFiled = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal)
if (Test-Path -LiteralPath $group -PathType Container) {
    $groupStart = (Get-Item -LiteralPath $group -Force).FullName.TrimEnd([char]'/', [char]'\')
    foreach ($file in @(Get-ChildItem -LiteralPath $group -File -Recurse -Force -Filter '*.md' -ErrorAction SilentlyContinue)) {
        if ($file.Name.StartsWith('.', [StringComparison]::Ordinal)) { continue }
        $null = $preFiled.Add($file.FullName.Substring($groupStart.Length).TrimStart([char]'/', [char]'\').Replace('\', '/'))
    }
}

# New-NSArchiveFolder <dir> - create a folder inside the shift's folder, refusing one reached
# through a reparse point.
function New-NSArchiveFolder {
    param([string]$Directory)
    $null = New-Item -ItemType Directory -Path $Directory -Force
    if ((Get-Item -LiteralPath $Directory -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) {
        Write-NSArchiveReceiptsError 'archive-receipts: refuse to write through a symlink archive path'
        exit 2
    }
}

# Copy-NSArchiveRecord <source> <directory> <keep|closed|own> - file one record, verify it, and
# retire the source when the shift has ended: closed when it was named or its item is ticked, own
# always, keep never. True when the record now has a verified archived copy.
function Copy-NSArchiveRecord {
    param([string]$Source, [string]$Directory, [string]$Rule)
    $base = [IO.Path]::GetFileName($Source)
    if ($base.StartsWith('.', [StringComparison]::Ordinal) -or $base -ceq '') { return $false }
    New-NSArchiveFolder $Directory
    $target = Join-Path $Directory $base
    # The leaf is checked too. A reparse point left where this record is about to land would carry
    # its bytes somewhere else and then read back as a faithful copy, so the source stays put.
    if (-not (Test-NSArchiveDest $target)) {
        $kept.Add($base + ' (a link or a directory is in the way of its archived copy)')
        return $false
    }
    if (Test-Path -LiteralPath $target) {
        if (-not (Test-NSArchiveSame $Source $target $group $ns)) {
            # Two different records under one name. Neither is worth losing, so the one already
            # filed stands and the live one stays where it is.
            $kept.Add($base + ' (a different record is already filed under that name)')
            return $false
        }
    }
    else {
        Copy-Item -LiteralPath $Source -Destination $target -Force
        $script:copied++
        if (-not (Test-NSSameFileBytes $Source $target)) {
            $kept.Add($base + ' (the archived copy does not match the source)')
            return $false
        }
    }
    $null = $filed.Add($base)
    if (-not $rotate) { return $true }
    if ($Rule -ceq 'keep') { return $true }
    if ($Rule -ceq 'closed' -and -not (($Retire -ccontains $base) -or ($script:tickedNames -ccontains $base))) { return $true }
    Remove-Item -LiteralPath $Source -Force -ErrorAction SilentlyContinue
    if (Test-Path -LiteralPath $Source) {
        $kept.Add($base + ' (could not be removed from live storage)')
        return $true
    }
    $script:removed++
    return $true
}

# Copy-NSArchiveFolder <live-dir> <archived-dir> <name> - one folder of readings, filed as one
# record: under its own name, only when every record in it was, and removed from live storage only
# then.
function Copy-NSArchiveFolder {
    param([string]$Live, [string]$To, [string]$Name)
    $whole = $true
    foreach ($entry in @(Get-ChildItem -LiteralPath $Live -Force -ErrorAction SilentlyContinue |
            Where-Object { -not $_.Name.StartsWith('.') })) {
        if ($entry.PSIsContainer -or ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
            $whole = $false
            continue
        }
        if (-not (Copy-NSArchiveRecord $entry.FullName $To 'keep')) { $whole = $false }
    }
    if (-not $whole) {
        $kept.Add($Name + ' (not every record in it could be filed)')
        return
    }
    $null = $filed.Add($Name)
    if (-not $rotate) { return }
    Remove-Item -LiteralPath $Live -Recurse -Force -ErrorAction SilentlyContinue
    if (Test-Path -LiteralPath $Live) { $kept.Add($Name + ' (could not be removed from live storage)') }
    else { $script:removed++ }
}

# The receipts of items nobody finished. They stay live and are not filed, exactly as the box stays
# in the punch list, so the next shift extends the same file; they are filed once their item is
# ticked.
$openNames = @(Get-NSOpenReceiptNames $workspace)
if (Test-Path -LiteralPath $src -PathType Container) {
    # The index is a view of a set of receipts, so each side of the move gets its own, written
    # below from what is actually there. The live one is never filed as a record of its own.
    $files = @(Get-ChildItem -LiteralPath $src -File -Force -ErrorAction SilentlyContinue |
        Where-Object {
            -not $_.Name.StartsWith('.') -and
            -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -and
            $_.Name -cne 'README.md'
        })
    foreach ($file in (Sort-NSOrdinal @($files | ForEach-Object { $_.Name }))) {
        # An open item's receipt stays live only, until its item is ticked.
        if ($openNames -ccontains $file) { continue }
        $null = Copy-NSArchiveRecord (Join-Path $src $file) $dest 'closed'
    }
}

# Once the shift has ended, its own records follow it: the usage readings, the policy when no
# clock-out filed it, and the shift log.
if ($rotate) {
    $usageDir = Get-NSLayoutPath $ns 'usage'
    if ((Test-Path -LiteralPath $usageDir -PathType Container) -and -not (Test-NSReparsePoint $usageDir)) {
        Copy-NSArchiveFolder $usageDir (Join-NSArchiveRel $group (Get-NSArchiveRel 'usage')) (Split-Path -Leaf $usageDir)
    }
    $policyFile = Get-NSLayoutPath $ns 'shift-policy'
    if ((Test-Path -LiteralPath $policyFile -PathType Leaf) -and -not (Test-NSReparsePoint $policyFile) -and
        -not [string]::IsNullOrEmpty($policyId) -and $policyId -ceq $shiftId) {
        $policyRel = Get-NSArchiveRel 'shift-policy'
        $policyDir = $group
        if ($policyRel.Contains('/')) { $policyDir = Join-NSArchiveRel $group $policyRel.Substring(0, $policyRel.LastIndexOf('/')) }
        if (Copy-NSArchiveRecord $policyFile $policyDir 'own') {
            $filedLines.Add('archive-receipts: filed the shift policy as ' + (Join-NSArchiveRel $group $policyRel))
        }
    }
    try {
        $journal = Save-NSArchiveJournal $workspace $group
        if (-not [string]::IsNullOrEmpty($journal)) { $filedLines.Add('archive-receipts: filed the shift log as ' + $journal) }
    }
    catch {
        $kept.Add((Split-Path -Leaf (Get-NSLayoutPath $ns 'shift-log')) + ' (the shift log could not be filed)')
    }
}

# A usage-<id>/ folder is a closed shift's readings, set aside by the Start preflight. It goes to the
# folder that shift claimed, at the path the readings have live, or into this shift's folder under
# its own name when no folder is that shift's or its readings are already there.
$usagePrefix = Get-NSLayoutPath $ns 'usage-shift' ''
$usageParent = Split-Path -Parent $usagePrefix
$usageLead = Split-Path -Leaf $usagePrefix
if ((Test-Path -LiteralPath $usageParent -PathType Container) -and -not (Test-NSReparsePoint $usageParent)) {
    $usageFolders = @(Get-ChildItem -LiteralPath $usageParent -Directory -Force -ErrorAction SilentlyContinue |
        Where-Object {
            $_.Name.StartsWith($usageLead, [StringComparison]::Ordinal) -and $_.Name.Length -gt $usageLead.Length -and
            -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint)
        })
    foreach ($name in (Sort-NSOrdinal @($usageFolders | ForEach-Object { $_.Name }))) {
        $instance = $name.Substring($usageLead.Length)
        $ownerDir = Get-NSArchiveFolderOf $workspace $instance
        $usageRel = Get-NSArchiveRel 'usage'
        if (-not [string]::IsNullOrEmpty($ownerDir) -and -not (Test-Path -LiteralPath (Join-NSArchiveRel $ownerDir $usageRel))) {
            $to = Join-NSArchiveRel $ownerDir $usageRel
        }
        else {
            $to = Join-NSArchiveRel $group (Get-NSArchiveRel 'usage-shift' $instance)
        }
        Copy-NSArchiveFolder (Join-Path $usageParent $name) $to $name
    }
}

# A leftover shift-report.md (not yet migrated into receipts/) still travels.
$report = Join-NSPath $ns (Get-NSLayoutRelativePathAt 0 'previous-report')
if ((Test-Path -LiteralPath $report -PathType Leaf) -and -not (Test-NSReparsePoint $report)) {
    $null = Copy-NSArchiveRecord $report $group 'closed'
}

# The parking lot and the snag log: their handled entries are filed, their open entries stay live.
# They are written with their links already repointed, so the link pass below leaves them alone.
$label = Get-NSArchiveReviewLabel (Split-Path -Leaf $group) $shiftId ([string](Get-NSPolicyGroupSetting $workspace 'archive.layout')['value'])
$repointed = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal)
$null = $repointed.Add((Get-NSArchiveRel 'punch-list'))
foreach ($key in @('snag-log', 'parking-lot')) {
    $null = $repointed.Add((Get-NSArchiveRel $key))
    try {
        $null = Save-NSArchiveReviewSource $workspace $key $group $label
    }
    catch {
        Write-NSArchiveReceiptsError 'archive-receipts: could not file snag or parking records'
        exit 2
    }
}
try {
    Add-NSArchiveBrokenPointers $workspace
}
catch {
    Write-NSArchiveReceiptsError 'archive-receipts: could not check the filed pointers'
    exit 2
}

# The punch list, once the shift has ended: filed whole, then only the contract and the open items
# live. While it is armed the list is its contract and nothing here touches it.
if ($rotate) {
    $punch = Save-NSArchivePunchList -Workspace $workspace -Folder $group -ShiftId $shiftId -Date $Date
    if ($punch.Status -eq 0) {
        if ($punch.Path -cne '') { $filedLines.Add('archive-receipts: filed the punch list as ' + $punch.Path) }
    }
    elseif ($punch.Status -eq 3) {
        Write-NSArchiveReceiptsError ('archive-receipts: a different punch list is already filed at ' + (Join-NSArchiveRel $group (Get-NSArchiveRel 'punch-list')) + '; the live list is unchanged')
    }
    else {
        Write-NSArchiveReceiptsError ('archive-receipts: could not file the punch list into ' + $group + ': ' + $punch.Reason)
    }
}

# The archive gets the index of what landed in it, written before the link pass so a receipt that
# links to its index has one to link to.
if (Test-Path -LiteralPath $dest -PathType Container) {
    Write-NSArchiveReceiptsIndex -Directory $dest -Date (Get-NSReceiptsShiftDate $workspace) -OpenNames $openNames
}

# Every page filed by this run keeps working from where it now sits. A record filed beside it is
# reached exactly as written; one that stayed live is further away and its link says so. A page
# filed on an earlier run was repointed then and is left alone. The shift log is raw evidence and
# stays as written.
$archivedPaths = New-Object Collections.Generic.List[string]
$groupFull = (Get-Item -LiteralPath $group -Force).FullName.TrimEnd([char]'/', [char]'\')
foreach ($file in @(Get-ChildItem -LiteralPath $group -File -Recurse -Force -ErrorAction SilentlyContinue)) {
    if ($file.Name.StartsWith('.', [StringComparison]::Ordinal)) { continue }
    if ($file.Name.EndsWith('.original.md', [StringComparison]::Ordinal)) { continue }
    $archivedPaths.Add($file.FullName.Substring($groupFull.Length).TrimStart([char]'/', [char]'\').Replace('\', '/'))
}
$journalRel = Get-NSArchiveRel 'shift-log'
foreach ($rel in (Sort-NSOrdinal $archivedPaths.ToArray())) {
    if (-not $rel.EndsWith('.md', [StringComparison]::Ordinal)) { continue }
    if ($rel -ceq $journalRel) { continue }
    # The index is written into the folder it describes: its links are already siblings there.
    if ($rel -ceq ($receiptsRel + '/README.md')) { continue }
    if ($preFiled.Contains($rel) -or $repointed.Contains($rel)) { continue }
    $page = Join-NSArchiveRel $group $rel
    if (Test-NSReparsePoint $page) { continue }
    $source = [IO.File]::ReadAllText($page, $utf8)
    $relocated = Get-NSArchiveRelocated $source $page $group $ns
    if ($relocated -ceq $source) { continue }
    [IO.File]::WriteAllText($page, $relocated, $utf8)
}

# The live folder lists the work still in hand - losing its index when there is nothing left
# to list.
Write-NSReceiptsIndex -Workspace $workspace -Remaining

$unmatched = @($Retire | Where-Object { -not $filed.Contains($_) })
if ($unmatched.Count -gt 0) {
    Write-NSArchiveReceiptsError 'archive-receipts: refused to retire - this run filed no such record:'
    foreach ($name in $unmatched) { Write-NSArchiveReceiptsError ('  ' + $name) }
}

if ($kept.Count -gt 0) {
    Write-NSArchiveReceiptsError 'archive-receipts: kept in live storage:'
    foreach ($line in $kept) { Write-NSArchiveReceiptsError $line }
}

if ($script:copied -ne 0 -or $script:removed -ne 0 -or $filedLines.Count -gt 0) {
    Write-Output $group
    if ($script:removed -gt 0) {
        Write-NSArchiveReceiptsError ("archive-receipts: retired {0} closed record(s) from live storage" -f $script:removed)
    }
}
foreach ($line in $filedLines) { Write-Output $line }
exit 0

SHA-256: 306363ee0334abd703327642d890b6a6e7f78528d84fb40741f6e7d46d8051f5