2import { execSync, execFileSync } from 'child_process' 3import * as _ from 'lodash-es' 7import { existsSync, unlinkSync } from 'fs' 11export const guardCleanCommit = async (fnc: () => Promise<unknown>, commitMsg: string) => { 12 return await guard1CleanCommit(fnc, {commitMsg}) 15export const getUncommittedChanges = () => execSync('git status --porcelain=v2', { encoding: 'utf8', cwd: ptDir }).trim() 17// Parse git status --porcelain=v2 output to get file paths 18// v2 format for changed files: "1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>" 19// v2 format for untracked: "? <path>" 20// v2 format for renames: "2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <origPath>\t<path>" 21export const parseUncommittedPaths = (statusOutput: string): string[] => { 22 if (!statusOutput) return [] 23 return statusOutput.split('\n').map(line => { 24 if (line.startsWith('? ')) { 25 // Untracked file: "? <path>" 27 } else if (line.startsWith('1 ')) { 28 // Changed file: path is last space-separated field 29 const parts = line.split(' ') 30 return parts[parts.length - 1] 31 } else if (line.startsWith('2 ')) { 32 // Renamed file: "2 ... <origPath>\t<path>" - path after tab 33 const tabIdx = line.indexOf('\t') 35 return line.slice(tabIdx + 1) 37 // Fallback: last space-separated field 38 const parts = line.split(' ') 39 return parts[parts.length - 1] 41 // Fallback for unknown format 42 return line.split(' ').pop() || line 46export const guardUncommittedChanges = () => { 47 const uncommittedChanges = getUncommittedChanges() 48 if (uncommittedChanges) { 49 throPtErr('uncommittedChangesExist - commit or stash before running', {uncommittedChanges}) 53// Check if a file matches uncommitted paths, handling untracked directories 54// Git shows untracked directories with trailing "/" instead of individual files 55const fileMatchesUncommitted = (file: string, uncommittedPaths: string[]): boolean => { 57 if (uncommittedPaths.includes(file)) return true 58 // File is inside an untracked directory (directories end with /) 59 return uncommittedPaths.some(p => p.endsWith('/') && file.startsWith(p)) 62// Check only if specific relevant files have uncommitted changes 63// relevantFilesA should be pt-relative paths (e.g., "pt0/foo/bar.mjs") 64export const guardRelevantFilesClean = (relevantFilesA: string[]) => { 65 const uncommittedChanges = getUncommittedChanges() 66 if (!uncommittedChanges) return 68 const uncommittedPaths = parseUncommittedPaths(uncommittedChanges) 69 const conflictingFiles = relevantFilesA.filter(f => fileMatchesUncommitted(f, uncommittedPaths)) 71 if (conflictingFiles.length > 0) { 73 `Blocked: ${conflictingFiles.length} file(s) this operation needs to modify have uncommitted changes:\n` + 74 conflictingFiles.map(f => ` ${f}`).join('\n') + 75 `\nCommit or revert changes to these specific files before retrying. Do NOT stash/commit unrelated files.`, 76 {conflictingFiles, relevantFilesA} 81// Commit only specific files (not git add -A) 82const commitRelevantFiles = (filesA: string[], commitMsg: string) => { 83 if (filesA.length === 0) { 84 console.log('nothing to commit, no files changed') 87 execSync(`git add -- ${filesA.map(shQuote).join(' ')}`, { encoding: 'utf8', cwd: ptDir }) 88 execFileSync('git', ['commit', '-m', commitMsg], { encoding: 'utf8', cwd: ptDir }) 91const isTrackedByGit = (ptRelPath: string): boolean => { 93 execSync(`git cat-file -e HEAD:${ptRelPath}`, { cwd: ptDir, stdio: 'pipe' }) 95 } catch { return false } 98const rollbackRelevantFiles = (relevantFilesA: string[]) => { 99 const trackedA = relevantFilesA.filter(isTrackedByGit) 100 if (trackedA.length > 0) { 101 const quoted = trackedA.map(shQuote).join(' ') 102 execSync(`git checkout HEAD -- ${quoted}`, { encoding: 'utf8', cwd: ptDir, stdio: 'pipe' }) 104 for (const f of relevantFilesA) { 106 if (!isTrackedByGit(f) && existsSync(absPath)) { 107 try { unlinkSync(absPath) } catch {} // catch:userapproved 112// Get all violations for existing files (boundaries + style) 113const getViolationsForFilesA = async (filesA: string[]): Promise<string[]> => { 114 const violationsA: string[] = [] 115 for (const f of filesA) { 118 violationsA.push(...fileViolations) 123export type GuardOpts = {commitMsg?: string, action?: string, relevantFilesA?: string[], skipViolationCheck?: boolean} 124export type GuardFnc = () => Promise<unknown> 126export const guard1CleanCommit = async (fnc: GuardFnc, {commitMsg, action, relevantFilesA, skipViolationCheck}: GuardOpts) => { 127 if (action == 'info') { 131 if (!_.startsWith(commitMsg, 'pt')) throPtErr(`!_.startsWith(commitMsg, 'pt')`) 135 return await guard1CleanCommitInner(fnc, {commitMsg, relevantFilesA, skipViolationCheck}) 141// Files dirtied by the mutation = post-mutation dirty set minus pre-existing dirty (unrelated WIP). 142// Using the actual delta (not the predicted relevantFilesA) ensures drift/unpredicted files the 143// mutation wrote get committed AND rolled back, fixing half-applied dirty trees on throw/drift. 144export const computeTouchedA = (postDirtyA: string[], preDirtyA: string[]): string[] => { 145 const preSet = new Set(preDirtyA) 146 return postDirtyA.filter(f => !preSet.has(f)) 149const guard1CleanCommitInner = async (fnc: GuardFnc, {commitMsg, relevantFilesA, skipViolationCheck}: GuardOpts) => { 150 // Use relevant files guard if provided, otherwise guard all changes 151 if (relevantFilesA) { 152 guardRelevantFilesClean(relevantFilesA) 154 guardUncommittedChanges() 157 // Snapshot pre-existing dirty files (unrelated WIP) to exclude from the commit/rollback delta 158 const preDirtyA = parseUncommittedPaths(getUncommittedChanges()) 160 // Capture pre-existing violations BEFORE running the operation (for relevant files that exist) 161 const preExistingViolationsA = relevantFilesA && !skipViolationCheck 162 ? await getViolationsForFilesA(relevantFilesA.filter(f => existsSync(f))) 169 const touchedA = computeTouchedA(parseUncommittedPaths(getUncommittedChanges()), preDirtyA) 170 if (touchedA.length > 0) { 171 console.error('Mutation threw mid-write, rolling back...') 172 rollbackRelevantFiles(touchedA) 176 if (!getUncommittedChanges()) { 177 console.log('nothing to commit, working tree clean') 181 // Commit the actual mutation footprint (delta vs preDirtyA), not the predicted set 182 if (relevantFilesA) { 183 const touchedA = computeTouchedA(parseUncommittedPaths(getUncommittedChanges()), preDirtyA) 184 if (touchedA.length === 0) { 185 console.log('nothing to commit, no relevant files changed') 189 // Run import boundary check on files that exist (skip deleted files, check new destinations) 190 // Only fail if NEW violations were introduced (not pre-existing ones in touched files) 191 if (!skipViolationCheck) { 192 const existingChangedFiles = touchedA.filter(f => existsSync(f)) 193 const currentViolationsA = await getViolationsForFilesA(existingChangedFiles) 194 const newViolationsA = currentViolationsA.filter(v => !preExistingViolationsA.includes(v)) 195 if (newViolationsA.length > 0) { 196 throw new Error(`Import boundary violations:\n${newViolationsA.map(v => ' ' + v).join('\n')}`) 199 commitRelevantFiles(touchedA, commitMsg!) 200 } catch (commitErr) { 201 console.error('Commit failed, rolling back file changes...') 202 rollbackRelevantFiles(touchedA) 206 execSync('git add -A', { encoding: 'utf8' }) 207 execFileSync('git', ['commit', '-m', commitMsg!], { encoding: 'utf8' })