🌳
pt0/deployF/gitF/guardCleanCommitF.mts
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>"
26 return line.slice(2)
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')
34 if (tabIdx !== -1) {
35 return line.slice(tabIdx + 1)
36 }
37 // Fallback: last space-separated field
38 const parts = line.split(' ')
39 return parts[parts.length - 1]
40 }
41 // Fallback for unknown format
42 return line.split(' ').pop() || line
43 })
46export const guardUncommittedChanges = () => {
47 const uncommittedChanges = getUncommittedChanges()
48 if (uncommittedChanges) {
49 throPtErr('uncommittedChangesExist - commit or stash before running', {uncommittedChanges})
50 }
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 => {
56 // Direct match
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}
77 )
78 }
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')
85 return
86 }
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 => {
92 try {
93 execSync(`git cat-file -e HEAD:${ptRelPath}`, { cwd: ptDir, stdio: 'pipe' })
94 return true
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' })
103 }
104 for (const f of relevantFilesA) {
105 const absPath = `${ptDir}/${f}`
106 if (!isTrackedByGit(f) && existsSync(absPath)) {
107 try { unlinkSync(absPath) } catch {} // catch:userapproved
108 }
109 }
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) {
116 if (!existsSync(f) || !isPtCodeFileExt(f)) continue
117 const fileViolations = await checkAllRulesForFile(f)
118 violationsA.push(...fileViolations)
119 }
120 return violationsA
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') {
128 return await fnc()
129 }
131 if (!_.startsWith(commitMsg, 'pt')) throPtErr(`!_.startsWith(commitMsg, 'pt')`)
134 try {
135 return await guard1CleanCommitInner(fnc, {commitMsg, relevantFilesA, skipViolationCheck})
136 } finally {
138 }
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)
153 } else {
154 guardUncommittedChanges()
155 }
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)))
163 : []
165 let fncRet: unknown
166 try {
167 fncRet = await fnc()
168 } catch (mutErr) {
169 const touchedA = computeTouchedA(parseUncommittedPaths(getUncommittedChanges()), preDirtyA)
170 if (touchedA.length > 0) {
171 console.error('Mutation threw mid-write, rolling back...')
172 rollbackRelevantFiles(touchedA)
173 }
174 throw mutErr
175 }
176 if (!getUncommittedChanges()) {
177 console.log('nothing to commit, working tree clean')
178 return fncRet
179 }
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')
186 return fncRet
187 }
188 try {
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')}`)
197 }
198 }
199 commitRelevantFiles(touchedA, commitMsg!)
200 } catch (commitErr) {
201 console.error('Commit failed, rolling back file changes...')
202 rollbackRelevantFiles(touchedA)
203 throw commitErr
204 }
205 } else {
206 execSync('git add -A', { encoding: 'utf8' })
207 execFileSync('git', ['commit', '-m', commitMsg!], { encoding: 'utf8' })
208 }
210 return fncRet