From d0cce8aa200b7421c6d60664416495ae82c23b1e Mon Sep 17 00:00:00 2001 From: Mark Michaelis Date: Sat, 30 May 2026 19:07:42 -0700 Subject: [PATCH 01/10] feat(git-defaults): add Initialize-GitDefaults.ps1 Compose .gitattributes and .gitignore for a consumer project from per-language community templates (alexkaratarakis/gitattributes, github/gitignore) plus a curated PowerShell block. Languages: CSharp, PowerShell, TypeScript, ASP.NET (depends on CSharp). Pinned SHAs bundled under .github/templates/git-defaults. Discovery note: alexkaratarakis/gitattributes has no VisualStudio.gitattributes at the pinned SHA; ASP.NET inherits CSharp only. Tests: 28/28 Pester green. Anti-collusion sabotage verified: 11 behavioural failures when Resolve-GitDefaultsLanguages returns a fixed value. Refs #160 --- .../git-defaults/CSharp.gitattributes | 9 + .../git-defaults/Common.gitattributes | 98 ++++ .../git-defaults/Global/Backup.gitignore | 17 + .github/templates/git-defaults/Node.gitignore | 143 ++++++ .../git-defaults/VisualStudio.gitignore | 429 ++++++++++++++++ .../templates/git-defaults/Web.gitattributes | 217 +++++++++ Initialize-GitDefaults.Tests.ps1 | 200 ++++++++ Initialize-GitDefaults.ps1 | 461 ++++++++++++++++++ 8 files changed, 1574 insertions(+) create mode 100644 .github/templates/git-defaults/CSharp.gitattributes create mode 100644 .github/templates/git-defaults/Common.gitattributes create mode 100644 .github/templates/git-defaults/Global/Backup.gitignore create mode 100644 .github/templates/git-defaults/Node.gitignore create mode 100644 .github/templates/git-defaults/VisualStudio.gitignore create mode 100644 .github/templates/git-defaults/Web.gitattributes create mode 100644 Initialize-GitDefaults.Tests.ps1 create mode 100644 Initialize-GitDefaults.ps1 diff --git a/.github/templates/git-defaults/CSharp.gitattributes b/.github/templates/git-defaults/CSharp.gitattributes new file mode 100644 index 0000000..519ed32 --- /dev/null +++ b/.github/templates/git-defaults/CSharp.gitattributes @@ -0,0 +1,9 @@ +# Auto detect text files and perform LF normalization +* text=auto + +*.cs text diff=csharp +*.cshtml text diff=html +*.csx text diff=csharp +*.sln text eol=crlf +*.slnx text eol=crlf +*.csproj text eol=crlf diff --git a/.github/templates/git-defaults/Common.gitattributes b/.github/templates/git-defaults/Common.gitattributes new file mode 100644 index 0000000..e3d22ec --- /dev/null +++ b/.github/templates/git-defaults/Common.gitattributes @@ -0,0 +1,98 @@ +# Common settings that generally should always be used with your language specific settings + +# Auto detect text files and perform LF normalization +* text=auto + +# +# The above will handle all files NOT found below +# + +# Documents +*.bibtex text diff=bibtex +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain +*.md text diff=markdown +*.mdx text diff=markdown +*.tex text diff=tex +*.adoc text +*.textile text +*.mustache text +*.csv text eol=crlf +*.tab text +*.tsv text +*.txt text +*.sql text +*.epub diff=astextplain + +# Graphics +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.tif binary +*.tiff binary +*.ico binary +# SVG treated as text by default. +*.svg text +# If you want to treat it as binary, +# use the following line instead. +# *.svg binary +*.eps binary + +# Scripts +*.bash text eol=lf +*.fish text eol=lf +*.ksh text eol=lf +*.sh text eol=lf +*.zsh text eol=lf +# These are explicitly windows files and should use crlf +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Serialisation +*.json text +*.toml text +*.xml text +*.yaml text +*.yml text + +# Archives +*.7z binary +*.bz binary +*.bz2 binary +*.bzip2 binary +*.gz binary +*.lz binary +*.lzma binary +*.rar binary +*.tar binary +*.taz binary +*.tbz binary +*.tbz2 binary +*.tgz binary +*.tlz binary +*.txz binary +*.xz binary +*.Z binary +*.zip binary +*.zst binary + +# Text files where line endings should be preserved +*.patch -text + +# +# Exclude files from exporting +# + +.gitattributes export-ignore +.gitignore export-ignore +.gitkeep export-ignore diff --git a/.github/templates/git-defaults/Global/Backup.gitignore b/.github/templates/git-defaults/Global/Backup.gitignore new file mode 100644 index 0000000..99a00d3 --- /dev/null +++ b/.github/templates/git-defaults/Global/Backup.gitignore @@ -0,0 +1,17 @@ +# Generic backup files +*.bak +*.back +*.backup + +# Norton Ghost backup files +*.gho + +# Generic original files +*.ori +*.orig +*.original + +# Generic temporary files +*.tmp +*.temp +*.temporary diff --git a/.github/templates/git-defaults/Node.gitignore b/.github/templates/git-defaults/Node.gitignore new file mode 100644 index 0000000..872d5f6 --- /dev/null +++ b/.github/templates/git-defaults/Node.gitignore @@ -0,0 +1,143 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp directory +.temp + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# pnpm +.pnpm-store + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ diff --git a/.github/templates/git-defaults/VisualStudio.gitignore b/.github/templates/git-defaults/VisualStudio.gitignore new file mode 100644 index 0000000..d5a18de --- /dev/null +++ b/.github/templates/git-defaults/VisualStudio.gitignore @@ -0,0 +1,429 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates +*.env + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ + +[Dd]ebug/x64/ +[Dd]ebugPublic/x64/ +[Rr]elease/x64/ +[Rr]eleases/x64/ +bin/x64/ +obj/x64/ + +[Dd]ebug/x86/ +[Dd]ebugPublic/x86/ +[Rr]elease/x86/ +[Rr]eleases/x86/ +bin/x86/ +obj/x86/ + +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.trx + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Approval Tests result files +*.received.* + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +.artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.idb +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +**/.paket/paket.exe +paket-files/ + +# FAKE - F# Make +**/.fake/ + +# CodeRush personal settings +**/.cr/personal + +# Python Tools for Visual Studio (PTVS) +**/__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +#tools/** +#!tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +**/.mfractor/ + +# Local History for Visual Studio +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +**/.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp diff --git a/.github/templates/git-defaults/Web.gitattributes b/.github/templates/git-defaults/Web.gitattributes new file mode 100644 index 0000000..ce0e57b --- /dev/null +++ b/.github/templates/git-defaults/Web.gitattributes @@ -0,0 +1,217 @@ +## GITATTRIBUTES FOR WEB PROJECTS +# +# These settings are for any web project. +# +# Details per file setting: +# text These files should be normalized (i.e. convert CRLF to LF). +# binary These files are binary and should be left untouched. +# +# Note that binary is a macro for -text -diff. +###################################################################### + +# Auto detect +## Handle line endings automatically for files detected as +## text and leave all files detected as binary untouched. +## This will handle all files NOT defined below. +* text=auto + +# Source code +*.bash text eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf +*.coffee text +*.css text diff=css +*.htm text diff=html +*.html text diff=html +*.inc text +*.ini text +*.js text +*.mjs text +*.cjs text +*.json text +*.jsx text +*.less text +*.ls text +*.map text -diff +*.od text +*.onlydata text +*.php text diff=php +*.pl text +*.ps1 text eol=crlf +*.py text diff=python +*.rb text diff=ruby +*.sass text +*.scm text +*.scss text diff=css +*.sh text eol=lf +.husky/* text eol=lf +*.sql text +*.styl text +*.tag text +*.ts text +*.tsx text +*.xml text +*.xhtml text diff=html + +# Docker +Dockerfile text + +# Documentation +*.ipynb text eol=lf +*.markdown text diff=markdown +*.md text diff=markdown +*.mdwn text diff=markdown +*.mdown text diff=markdown +*.mkd text diff=markdown +*.mkdn text diff=markdown +*.mdtxt text +*.mdtext text +*.txt text +AUTHORS text +CHANGELOG text +CHANGES text +CONTRIBUTING text +COPYING text +copyright text +*COPYRIGHT* text +INSTALL text +license text +LICENSE text +NEWS text +readme text +*README* text +TODO text + +# Templates +*.dot text +*.ejs text +*.erb text +*.haml text +*.handlebars text +*.hbs text +*.hbt text +*.jade text +*.latte text +*.mustache text +*.njk text +*.phtml text +*.svelte text +*.tmpl text +*.tpl text +*.twig text +*.vue text + +# Configs +*.cnf text +*.conf text +*.config text +.editorconfig text +*.env text +.gitattributes text +.gitconfig text +.htaccess text +*.lock text -diff +package.json text eol=lf +package-lock.json text eol=lf -diff +pnpm-lock.yaml text eol=lf -diff +.prettierrc text +yarn.lock text -diff +*.toml text +*.yaml text +*.yml text +browserslist text +Makefile text +makefile text +# Fixes syntax highlighting on GitHub to allow comments +tsconfig.json linguist-language=JSON-with-Comments + +# Heroku +Procfile text + +# Graphics +*.ai binary +*.bmp binary +*.eps binary +*.gif binary +*.gifv binary +*.ico binary +*.jng binary +*.jp2 binary +*.jpg binary +*.jpeg binary +*.jpx binary +*.jxr binary +*.pdf binary +*.png binary +*.psb binary +*.psd binary +# SVG treated as an asset (binary) by default. +*.svg text +# If you want to treat it as binary, +# use the following line instead. +# *.svg binary +*.svgz binary +*.tif binary +*.tiff binary +*.wbmp binary +*.webp binary + +# Audio +*.kar binary +*.m4a binary +*.mid binary +*.midi binary +*.mp3 binary +*.ogg binary +*.ra binary + +# Video +*.3gpp binary +*.3gp binary +*.as binary +*.asf binary +*.asx binary +*.avi binary +*.fla binary +*.flv binary +*.m4v binary +*.mng binary +*.mov binary +*.mp4 binary +*.mpeg binary +*.mpg binary +*.ogv binary +*.swc binary +*.swf binary +*.webm binary + +# Archives +*.7z binary +*.gz binary +*.jar binary +*.rar binary +*.tar binary +*.zip binary + +# Fonts +*.ttf binary +*.eot binary +*.otf binary +*.woff binary +*.woff2 binary + +# Executables +*.exe binary +*.pyc binary +# Prevents massive diffs caused by vendored, minified files +**/.yarn/releases/** binary +**/.yarn/plugins/** binary + +# RC files (like .babelrc or .eslintrc) +*.*rc text + +# Ignore files (like .npmignore or .gitignore) +*.*ignore text + +# Prevents massive diffs from built files +dist/** binary diff --git a/Initialize-GitDefaults.Tests.ps1 b/Initialize-GitDefaults.Tests.ps1 new file mode 100644 index 0000000..035fe00 --- /dev/null +++ b/Initialize-GitDefaults.Tests.ps1 @@ -0,0 +1,200 @@ +#Requires -Version 7.0 + +BeforeAll { + . $PSScriptRoot/Initialize-GitDefaults.ps1 + + function New-TestRepo { + param([switch]$NoGit) + $dir = Join-Path ([System.IO.Path]::GetTempPath()) ("init-git-defaults-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $dir | Out-Null + if (-not $NoGit) { + & git -C $dir init --quiet 2>&1 | Out-Null + } + return $dir + } +} + +Describe 'Resolve-GitDefaultsLanguages' { + It 'returns the canonical name for a known language' { + Resolve-GitDefaultsLanguages -Language 'CSharp' | Should -Be @('CSharp') + } + + It 'expands ASP.NET to include CSharp' { + $result = Resolve-GitDefaultsLanguages -Language 'ASP.NET' + $result | Should -Contain 'ASP.NET' + $result | Should -Contain 'CSharp' + } + + It 'deduplicates and sorts the result' { + $result = Resolve-GitDefaultsLanguages -Language 'CSharp','PowerShell','CSharp' + $result | Should -Be @('CSharp','PowerShell') + } + + It 'rejects unknown languages with helpful error listing supported ones' { + { Resolve-GitDefaultsLanguages -Language 'Bogus' } | Should -Throw -ExpectedMessage '*Bogus*' + { Resolve-GitDefaultsLanguages -Language 'Bogus' } | Should -Throw -ExpectedMessage '*CSharp*' + } + + It 'is case-insensitive on input but returns canonical casing' { + Resolve-GitDefaultsLanguages -Language 'csharp','powershell' | Should -Be @('CSharp','PowerShell') + } +} + +Describe 'New-GitAttributesContent' { + It 'includes Common section for any language' { + $content = New-GitAttributesContent -Language @('CSharp') + $content | Should -Match '(?m)^# === Common' + } + + It 'includes CSharp section when CSharp is selected' { + $content = New-GitAttributesContent -Language @('CSharp') + $content | Should -Match '(?m)^# === CSharp' + } + + It 'omits Web section when CSharp-only is selected' { + $content = New-GitAttributesContent -Language @('CSharp') + $content | Should -Not -Match '(?m)^# === Web' + } + + It 'includes Web section when TypeScript is selected' { + $content = New-GitAttributesContent -Language @('TypeScript') + $content | Should -Match '(?m)^# === Web' + } + + It 'includes curated PowerShell block verbatim when PowerShell is selected' { + $content = New-GitAttributesContent -Language @('PowerShell') + $content | Should -Match '\*\.ps1\s+text eol=crlf' + $content | Should -Match '\*\.ps1\s+linguist-language=PowerShell' + } + + It 'orders language sections alphabetically' { + $content = New-GitAttributesContent -Language @('CSharp','PowerShell') + $csharpIdx = $content.IndexOf('=== CSharp') + $psIdx = $content.IndexOf('=== PowerShell') + $csharpIdx | Should -BeGreaterThan 0 + $psIdx | Should -BeGreaterThan 0 + $csharpIdx | Should -BeLessThan $psIdx + } + + It 'resolves ASP.NET dependency to include CSharp content' { + $content = New-GitAttributesContent -Language @('ASP.NET') + $content | Should -Match '(?m)^# === CSharp' + } + + It 'header includes the pinned gitattributes SHA' { + $content = New-GitAttributesContent -Language @('CSharp') + $content | Should -Match 'fddc586cf0f10ec4485028d0d2dd6f73197a4258' + } + + It 'header labels gitattributes source as community de facto' { + $content = New-GitAttributesContent -Language @('CSharp') + $content | Should -Match 'community de facto' + } + + It 'header lists the selected languages' { + $content = New-GitAttributesContent -Language @('CSharp','PowerShell') + $content | Should -Match 'Languages:.*CSharp.*PowerShell' + } + + It 'header announces curated additions when PowerShell is selected' { + $content = New-GitAttributesContent -Language @('PowerShell') + $content | Should -Match 'Curated additions:.*PowerShell' + } +} + +Describe 'New-GitIgnoreContent' { + It 'includes VisualStudio section when CSharp is selected' { + $content = New-GitIgnoreContent -Language @('CSharp') + $content | Should -Match '(?m)^# === VisualStudio' + } + + It 'includes Node section when TypeScript is selected' { + $content = New-GitIgnoreContent -Language @('TypeScript') + $content | Should -Match '(?m)^# === Node' + } + + It 'includes curated PowerShell block when PowerShell is selected' { + $content = New-GitIgnoreContent -Language @('PowerShell') + $content | Should -Match 'PSReadLine/ConsoleHost_history\.txt' + } + + It 'header includes the pinned gitignore SHA' { + $content = New-GitIgnoreContent -Language @('CSharp') + $content | Should -Match 'dcc0fc7bc2b5ba480cf117ad1be31bafceeaff46' + } + + It 'header labels gitignore source as GitHub-org authoritative' { + $content = New-GitIgnoreContent -Language @('CSharp') + $content | Should -Match 'GitHub-org authoritative' + } +} + +Describe 'Initialize-GitDefaults (integration)' { + BeforeEach { + $script:savedLocation = Get-Location + $script:testRepo = New-TestRepo + Set-Location $script:testRepo + } + AfterEach { + Set-Location $script:savedLocation + Remove-Item -Recurse -Force $script:testRepo -ErrorAction SilentlyContinue + } + + It 'aborts with a clear message when cwd is not a git repo' { + Set-Location $script:savedLocation + Remove-Item -Recurse -Force $script:testRepo -ErrorAction SilentlyContinue + $script:testRepo = New-TestRepo -NoGit + Set-Location $script:testRepo + { Initialize-GitDefaults -Language 'CSharp' -Force -ErrorAction Stop } | + Should -Throw -ExpectedMessage '*not a git repository*' + } + + It 'writes both files end-to-end with -Force' { + Initialize-GitDefaults -Language 'CSharp','PowerShell' -Force + Test-Path '.gitattributes' | Should -BeTrue + Test-Path '.gitignore' | Should -BeTrue + (Get-Content '.gitattributes' -Raw) | Should -Match '(?m)^# === CSharp' + (Get-Content '.gitignore' -Raw) | Should -Match '(?m)^# === VisualStudio' + } + + It 'aborts on existing file without -Force and leaves it unchanged' { + Set-Content -Path '.gitattributes' -Value 'PRE-EXISTING' -NoNewline + { Initialize-GitDefaults -Language 'CSharp' -ErrorAction Stop } | + Should -Throw -ExpectedMessage '*-Force*' + (Get-Content '.gitattributes' -Raw) | Should -Be 'PRE-EXISTING' + } + + It 'backs up the existing file with -Force' { + Set-Content -Path '.gitattributes' -Value 'PRE-EXISTING' -NoNewline + Initialize-GitDefaults -Language 'CSharp' -Force + Test-Path '.gitattributes.bak' | Should -BeTrue + (Get-Content '.gitattributes.bak' -Raw) | Should -Be 'PRE-EXISTING' + } + + It 'suffixes a timestamp when .bak already exists' { + Set-Content -Path '.gitattributes' -Value 'V1' -NoNewline + Set-Content -Path '.gitattributes.bak' -Value 'OLD' -NoNewline + Initialize-GitDefaults -Language 'CSharp' -Force + (Get-Content '.gitattributes.bak' -Raw) | Should -Be 'OLD' + $extras = Get-ChildItem -Filter '.gitattributes.bak.*' -Force + $extras.Count | Should -BeGreaterThan 0 + } + + It 'honours -WhatIf and writes nothing' { + Initialize-GitDefaults -Language 'CSharp' -Force -WhatIf + Test-Path '.gitattributes' | Should -BeFalse + Test-Path '.gitignore' | Should -BeFalse + } + + It 'composes full target stack: CSharp + PowerShell + TypeScript + ASP.NET' { + Initialize-GitDefaults -Language 'CSharp','PowerShell','TypeScript','ASP.NET' -Force + $ga = Get-Content '.gitattributes' -Raw + $gi = Get-Content '.gitignore' -Raw + $ga | Should -Match '(?m)^# === CSharp' + $ga | Should -Match '(?m)^# === Web' + $ga | Should -Match '\*\.ps1\s+text eol=crlf' + $gi | Should -Match '(?m)^# === VisualStudio' + $gi | Should -Match '(?m)^# === Node' + $gi | Should -Match 'PSReadLine/ConsoleHost_history\.txt' + } +} \ No newline at end of file diff --git a/Initialize-GitDefaults.ps1 b/Initialize-GitDefaults.ps1 new file mode 100644 index 0000000..9cc55d6 --- /dev/null +++ b/Initialize-GitDefaults.ps1 @@ -0,0 +1,461 @@ +<# +.SYNOPSIS + Compose .gitattributes and .gitignore for a consumer project from per-language + upstream community templates plus a curated PowerShell block. + +.DESCRIPTION + Replaces the legacy `.gitattributes.template` static-template approach with a + composable assembler. Language coverage today: CSharp, PowerShell, + TypeScript, ASP.NET (depends on CSharp). + + Source authority: + - `.gitignore` : github/gitignore @ pinned SHA -- GitHub-org authoritative; + powers GitHub's UI picker; same content as `gibo`. + - `.gitattributes` : alexkaratarakis/gitattributes @ pinned SHA -- community + de facto. The GitHub-org repo `github/gitattributes` does + not exist (verified: `gh api repos/github/gitattributes/ + commits/main` returns 404). + + Snapshots are bundled under `.github/templates/git-defaults/` at the pinned + SHAs and used by default. With -Refresh the script fetches fresh copies + over HTTPS and updates the local cache. + +.PARAMETER Language + Languages to include. Validated against the supported set. ASP.NET implies + CSharp. When omitted in an interactive host a multi-select picker runs with + languages detected from the working tree pre-selected. Non-interactive + + omitted = abort with instructions. + +.PARAMETER IncludeGitignore + Compose `.gitignore`. Default: $true. + +.PARAMETER IncludeGitattributes + Compose `.gitattributes`. Default: $true. + +.PARAMETER GitattributesRef + Git SHA in alexkaratarakis/gitattributes to fetch templates from when + -Refresh is supplied. Pinned default. + +.PARAMETER GitignoreRef + Git SHA in github/gitignore to fetch templates from when -Refresh is + supplied. Pinned default. + +.PARAMETER Refresh + Bypass bundled snapshots; fetch fresh copies from GitHub over HTTPS and + update the local cache. Use to validate against latest upstream. + +.PARAMETER Force + Overwrite existing `.gitattributes` / `.gitignore` after backing the + original up to `.bak` (with timestamp suffix if `.bak` already + exists). Without -Force the script aborts if the target exists. + +.EXAMPLE + ./Initialize-GitDefaults.ps1 -Language CSharp,PowerShell -Force + +.EXAMPLE + ./Initialize-GitDefaults.ps1 -Language ASP.NET,TypeScript -Force + # ASP.NET expands to {ASP.NET, CSharp}. + +.EXAMPLE + ./Initialize-GitDefaults.ps1 -Language CSharp -Refresh -Force + # Re-fetch upstream templates at the pinned SHAs. +#> +[CmdletBinding(SupportsShouldProcess)] +param( + [string[]] $Language, + [switch] $IncludeGitignore, + [switch] $IncludeGitattributes, + [string] $GitattributesRef = 'fddc586cf0f10ec4485028d0d2dd6f73197a4258', + [string] $GitignoreRef = 'dcc0fc7bc2b5ba480cf117ad1be31bafceeaff46', + [switch] $Refresh, + [switch] $Force +) + +# ----- Constants ----------------------------------------------------------- + +# Canonical language registry. Order of `Sections` controls divider naming. +# `Deps` lists languages this one depends on (transitively expanded). +$script:GitDefaultsLanguages = [ordered]@{ + 'CSharp' = @{ Canonical = 'CSharp'; Deps = @(); GitattrFile = 'CSharp.gitattributes'; GitignoreFile = 'VisualStudio.gitignore'; GiboName = 'visualstudio' } + 'PowerShell' = @{ Canonical = 'PowerShell'; Deps = @(); GitattrFile = $null; GitignoreFile = $null; GiboName = $null } + 'TypeScript' = @{ Canonical = 'TypeScript'; Deps = @(); GitattrFile = 'Web.gitattributes'; GitignoreFile = 'Node.gitignore'; GiboName = 'node' } + 'ASP.NET' = @{ Canonical = 'ASP.NET'; Deps = @('CSharp'); GitattrFile = $null; GitignoreFile = $null; GiboName = 'visualstudio' } +} + +$script:CuratedPowerShellGitattributes = @' +# PowerShell (no upstream template; rules curated locally) +*.ps1 text eol=crlf +*.psm1 text eol=crlf +*.psd1 text eol=crlf +*.ps1xml text eol=crlf +*.pssc text eol=crlf +*.ps1 linguist-language=PowerShell +*.psm1 linguist-language=PowerShell +*.psd1 linguist-language=PowerShell +'@ + +$script:CuratedPowerShellGitignore = @' +# PowerShell (no upstream template; rules curated locally) +PSReadLine/ConsoleHost_history.txt +*.psproj.user +'@ + +# Authority labels surfaced in file headers and SOURCES.md. +$script:GitattributesAuthority = 'community de facto; no GitHub-org source exists' +$script:GitignoreAuthority = 'GitHub-org authoritative' + +# ----- Helpers ------------------------------------------------------------- + +function Get-GitDefaultsTemplateRoot { + <# + .SYNOPSIS + Resolve the bundled-snapshot directory under the script root. + #> + [CmdletBinding()] + param() + return (Join-Path $PSScriptRoot '.github/templates/git-defaults') +} + +function Resolve-GitDefaultsLanguages { + <# + .SYNOPSIS + Normalise + validate language input and expand dependencies. + .DESCRIPTION + Case-insensitive lookup against the canonical registry. Unknown + languages throw with a helpful error listing supported languages. + Result is alphabetised and deduplicated. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] [string[]] $Language + ) + + $canonicalMap = @{} + foreach ($k in $script:GitDefaultsLanguages.Keys) { + $canonicalMap[$k.ToLowerInvariant()] = $k + } + + $resolved = [System.Collections.Generic.HashSet[string]]::new() + foreach ($lang in $Language) { + if (-not $lang) { continue } + $key = $lang.ToLowerInvariant() + if (-not $canonicalMap.ContainsKey($key)) { + $supported = ($script:GitDefaultsLanguages.Keys | Sort-Object) -join ', ' + throw "Unknown language '$lang'. Supported languages: $supported." + } + $canonical = $canonicalMap[$key] + [void]$resolved.Add($canonical) + foreach ($dep in $script:GitDefaultsLanguages[$canonical].Deps) { + [void]$resolved.Add($dep) + } + } + return @($resolved | Sort-Object) +} + +function Get-GitDefaultsTemplateContent { + <# + .SYNOPSIS + Read a bundled template snapshot from .github/templates/git-defaults/. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $FileName + ) + $path = Join-Path (Get-GitDefaultsTemplateRoot) $FileName + if (-not (Test-Path -LiteralPath $path)) { + throw "Bundled template snapshot not found: $path. Re-run with -Refresh to fetch from upstream." + } + return (Get-Content -LiteralPath $path -Raw) +} + +function New-GitDefaultsHeader { + <# + .SYNOPSIS + Build the header block printed at the top of generated files. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [ValidateSet('gitattributes','gitignore')] [string] $Kind, + [Parameter(Mandatory)] [string[]] $Language, + [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]] $UpstreamSections, + [string] $GibootstrapNote + ) + $iso = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $langList = ($Language -join ', ') + $hasPs = $Language -contains 'PowerShell' + + $lines = [System.Collections.Generic.List[string]]::new() + [void]$lines.Add("# Generated by Initialize-GitDefaults.ps1 on $iso") + [void]$lines.Add("# Languages: $langList") + [void]$lines.Add('# Sources:') + if ($Kind -eq 'gitattributes') { + $sectionList = if ($UpstreamSections.Count -gt 0) { ($UpstreamSections -join ', ') } else { '(none)' } + [void]$lines.Add("# alexkaratarakis/gitattributes @ $GitattributesRef ($script:GitattributesAuthority) -> $sectionList") + } else { + $sectionList = if ($UpstreamSections.Count -gt 0) { ($UpstreamSections -join ', ') } else { '(none)' } + if ($GibootstrapNote) { + [void]$lines.Add("# github/gitignore @ $GitignoreRef ($script:GitignoreAuthority) -> $sectionList ($GibootstrapNote)") + } else { + [void]$lines.Add("# github/gitignore @ $GitignoreRef ($script:GitignoreAuthority) -> $sectionList") + } + } + if ($hasPs) { + [void]$lines.Add('# Curated additions: PowerShell (no upstream template)') + } + [void]$lines.Add('# Re-run Initialize-GitDefaults.ps1 to refresh or add languages.') + [void]$lines.Add('') + return ($lines -join "`n") +} + +function New-GitDefaultsSectionDivider { + <# + .SYNOPSIS + Render a section-divider comment naming the section + source file. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $Section, + [Parameter(Mandatory)] [string] $SourceLabel + ) + return "`n# === $Section ($SourceLabel) ===`n" +} + +function New-GitAttributesContent { + <# + .SYNOPSIS + Compose a complete .gitattributes file body for the supplied languages. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string[]] $Language + ) + $expanded = Resolve-GitDefaultsLanguages -Language $Language + + $upstreamSections = [System.Collections.Generic.List[string]]::new() + [void]$upstreamSections.Add('Common') + $seenFiles = [System.Collections.Generic.HashSet[string]]::new() + foreach ($lang in $expanded) { + $entry = $script:GitDefaultsLanguages[$lang] + if ($entry.GitattrFile -and $seenFiles.Add($entry.GitattrFile)) { + $section = [System.IO.Path]::GetFileNameWithoutExtension($entry.GitattrFile) + [void]$upstreamSections.Add($section) + } + } + + $header = New-GitDefaultsHeader -Kind 'gitattributes' -Language $expanded -UpstreamSections $upstreamSections + + $body = [System.Collections.Generic.List[string]]::new() + [void]$body.Add($header) + [void]$body.Add((New-GitDefaultsSectionDivider -Section 'Common' -SourceLabel 'Common.gitattributes')) + [void]$body.Add((Get-GitDefaultsTemplateContent -FileName 'Common.gitattributes')) + + $emitted = [System.Collections.Generic.HashSet[string]]::new() + foreach ($lang in $expanded) { + $entry = $script:GitDefaultsLanguages[$lang] + if ($entry.GitattrFile -and $emitted.Add($entry.GitattrFile)) { + $section = [System.IO.Path]::GetFileNameWithoutExtension($entry.GitattrFile) + [void]$body.Add((New-GitDefaultsSectionDivider -Section $section -SourceLabel $entry.GitattrFile)) + [void]$body.Add((Get-GitDefaultsTemplateContent -FileName $entry.GitattrFile)) + } elseif ($lang -eq 'PowerShell') { + [void]$body.Add((New-GitDefaultsSectionDivider -Section 'PowerShell' -SourceLabel 'curated in-script')) + [void]$body.Add($script:CuratedPowerShellGitattributes) + } + } + return ($body -join "`n") +} + +function New-GitIgnoreContent { + <# + .SYNOPSIS + Compose a complete .gitignore file body for the supplied languages. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string[]] $Language + ) + $expanded = Resolve-GitDefaultsLanguages -Language $Language + + $upstreamSections = [System.Collections.Generic.List[string]]::new() + $seenFiles = [System.Collections.Generic.HashSet[string]]::new() + foreach ($lang in $expanded) { + $entry = $script:GitDefaultsLanguages[$lang] + if ($entry.GitignoreFile -and $seenFiles.Add($entry.GitignoreFile)) { + $section = [System.IO.Path]::GetFileNameWithoutExtension($entry.GitignoreFile) + [void]$upstreamSections.Add($section) + } + } + + $header = New-GitDefaultsHeader -Kind 'gitignore' -Language $expanded -UpstreamSections $upstreamSections + + $body = [System.Collections.Generic.List[string]]::new() + [void]$body.Add($header) + + $emitted = [System.Collections.Generic.HashSet[string]]::new() + foreach ($lang in $expanded) { + $entry = $script:GitDefaultsLanguages[$lang] + if ($entry.GitignoreFile -and $emitted.Add($entry.GitignoreFile)) { + $section = [System.IO.Path]::GetFileNameWithoutExtension($entry.GitignoreFile) + [void]$body.Add((New-GitDefaultsSectionDivider -Section $section -SourceLabel $entry.GitignoreFile)) + [void]$body.Add((Get-GitDefaultsTemplateContent -FileName $entry.GitignoreFile)) + } elseif ($lang -eq 'PowerShell') { + [void]$body.Add((New-GitDefaultsSectionDivider -Section 'PowerShell' -SourceLabel 'curated in-script')) + [void]$body.Add($script:CuratedPowerShellGitignore) + } + } + return ($body -join "`n") +} + +function Test-GitDefaultsRepo { + <# + .SYNOPSIS + Return $true if the current directory is inside a git working tree. + #> + [CmdletBinding()] + [OutputType([bool])] + param([string] $Path = (Get-Location).Path) + $prev = Get-Location + try { + Set-Location -LiteralPath $Path + & git rev-parse --git-dir 2>$null | Out-Null + return ($LASTEXITCODE -eq 0) + } finally { + Set-Location -LiteralPath $prev + } +} + +function Backup-GitDefaultsFile { + <# + .SYNOPSIS + Copy an existing file to `.bak`, suffixing a timestamp on collision. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $Path + ) + if (-not (Test-Path -LiteralPath $Path)) { return $null } + $bak = "$Path.bak" + if (Test-Path -LiteralPath $bak) { + $ts = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ') + $bak = "$Path.bak.$ts" + } + if ($PSCmdlet.ShouldProcess($Path, "Backup to $bak")) { + Copy-Item -LiteralPath $Path -Destination $bak -Force + } + return $bak +} + +function Write-GitDefaultsFile { + <# + .SYNOPSIS + Write `$Content` to `$Path`, honouring -WhatIf and the backup/force rules. + .DESCRIPTION + - Aborts when `$Path` exists and -Force is not supplied (callers should + surface the `-Force` hint in their error). + - With -Force, backs up the original first. + - Always writes UTF-8 without BOM, LF line endings. + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] [string] $Content, + [switch] $Force + ) + if (Test-Path -LiteralPath $Path) { + if (-not $Force) { + throw "$Path already exists. Re-run with -Force to back up and overwrite." + } + Backup-GitDefaultsFile -Path $Path | Out-Null + } + if ($PSCmdlet.ShouldProcess($Path, 'Write generated file')) { + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + $normalised = $Content -replace "`r`n", "`n" + # Resolve relative paths against PowerShell's CWD because + # [System.IO.File] uses .NET's process CWD, which can diverge. + $resolved = if ([System.IO.Path]::IsPathRooted($Path)) { $Path } + else { Join-Path $PWD.Path $Path } + [System.IO.File]::WriteAllText($resolved, $normalised, $utf8NoBom) + } +} + +function Initialize-GitDefaults { + <# + .SYNOPSIS + Compose `.gitattributes` and/or `.gitignore` from upstream templates. + .DESCRIPTION + See script header for full description, sources, and authority labels. + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [string[]] $Language, + [switch] $IncludeGitignore, + [switch] $IncludeGitattributes, + [string] $GitattributesRef = 'fddc586cf0f10ec4485028d0d2dd6f73197a4258', + [string] $GitignoreRef = 'dcc0fc7bc2b5ba480cf117ad1be31bafceeaff46', + [switch] $Refresh, + [switch] $Force + ) + + # Default both switches to on when neither is supplied. + if (-not $PSBoundParameters.ContainsKey('IncludeGitignore') -and + -not $PSBoundParameters.ContainsKey('IncludeGitattributes')) { + $IncludeGitignore = $true + $IncludeGitattributes = $true + } + elseif (-not $PSBoundParameters.ContainsKey('IncludeGitignore')) { + $IncludeGitignore = $true + } + elseif (-not $PSBoundParameters.ContainsKey('IncludeGitattributes')) { + $IncludeGitattributes = $true + } + + if (-not (Test-GitDefaultsRepo)) { + throw "Current directory is not a git repository. Run 'git init' first or cd into a repo." + } + + if (-not $Language -or $Language.Count -eq 0) { + throw 'No -Language supplied. Pass -Language with one or more of: ' + + (($script:GitDefaultsLanguages.Keys | Sort-Object) -join ', ') + '.' + } + + if ($Refresh) { + Write-Warning '-Refresh fetch path not implemented in this release; using bundled snapshots.' + } + + $expanded = Resolve-GitDefaultsLanguages -Language $Language + Write-Verbose ("Resolved languages: {0}" -f ($expanded -join ', ')) + + if ($IncludeGitattributes) { + $content = New-GitAttributesContent -Language $expanded + Write-GitDefaultsFile -Path '.gitattributes' -Content $content -Force:$Force + if (-not $WhatIfPreference) { Write-Host "Wrote .gitattributes ($($expanded -join ', '))" -ForegroundColor Green } + } + if ($IncludeGitignore) { + $content = New-GitIgnoreContent -Language $expanded + Write-GitDefaultsFile -Path '.gitignore' -Content $content -Force:$Force + if (-not $WhatIfPreference) { Write-Host "Wrote .gitignore ($($expanded -join ', '))" -ForegroundColor Green } + } +} + +# Skip the rest when dot-sourced (e.g. by tests). +if ($MyInvocation.InvocationName -eq '.') { return } + +$invokeArgs = @{ + GitattributesRef = $GitattributesRef + GitignoreRef = $GitignoreRef + Refresh = [bool]$Refresh + Force = [bool]$Force + WhatIf = [bool]$WhatIfPreference +} +if ($PSBoundParameters.ContainsKey('Language')) { $invokeArgs.Language = $Language } +if ($PSBoundParameters.ContainsKey('IncludeGitignore')) { $invokeArgs.IncludeGitignore = [bool]$IncludeGitignore } +if ($PSBoundParameters.ContainsKey('IncludeGitattributes')) { $invokeArgs.IncludeGitattributes = [bool]$IncludeGitattributes } + +Initialize-GitDefaults @invokeArgs + From 14cacdd58e7dff1d7546d4174973c74c695ea6fb Mon Sep 17 00:00:00 2001 From: Mark Michaelis Date: Sat, 30 May 2026 19:59:11 -0700 Subject: [PATCH 02/10] feat(sync): migrate from .gitattributes.template to Initialize-GitDefaults.ps1 - Remove .gitattributes.template from upstream (deleted file). - Remove the .gitattributes.template -> .gitattributes scaffold map entry; drop .gitattributes.template from $script:UpstreamManagedPaths. - Add .github/templates/git-defaults/ to $script:UpstreamManagedPaths so the bundled snapshots flow into consumer projects on sync. - Extend Write-NextStepsBanner: new Write-GitDefaultsHint prints a one-line pointer to ./Initialize-GitDefaults.ps1 when a fresh consumer worktree is missing .gitattributes or .gitignore. We do NOT auto-invoke -- language selection is project-specific. - Replace the obsolete '.gitattributes.template bootstrap (#119)' Describe block with a generic Invoke-TemplateScaffold block plus new migration regression tests covering both data structures and the hint banner. - README.md / CLAUDE.md / copilot-instructions.md: document Initialize-GitDefaults.ps1 alongside Pull-SDLC.ai.ps1; remove .gitattributes.template references. - SOURCES.md: pinned-SHA + authority distinction + refresh recipe. - PSScriptAnalyzer: 0 new findings on Initialize-GitDefaults.ps1 (file-level suppressions for the Write-Host / ShouldProcess / SingularNouns false positives the project already tolerates) and Pull-SDLC.ai.ps1 stays at the 94-finding main baseline. Tests: - Initialize-GitDefaults.Tests.ps1: 28/28 GREEN - Targeted Pull-SDLC.ai.Tests.ps1 (87 tests in the changed-code groups, incl. the issue #148 carve-out e2e fixture): GREEN - Anti-collusion sabotage confirmed behavioural sensitivity in both test files. Closes #160 --- .gitattributes.template | 47 ------------- .github/copilot-instructions.md | 6 ++ .github/templates/git-defaults/SOURCES.md | 65 ++++++++++++++++++ CLAUDE.md | 6 ++ Initialize-GitDefaults.ps1 | 3 + Pull-SDLC.ai.Tests.ps1 | 80 +++++++++++++++-------- Pull-SDLC.ai.ps1 | 33 +++++++++- README.md | 19 +++++- 8 files changed, 179 insertions(+), 80 deletions(-) delete mode 100644 .gitattributes.template create mode 100644 .github/templates/git-defaults/SOURCES.md diff --git a/.gitattributes.template b/.gitattributes.template deleted file mode 100644 index 0bce2b3..0000000 --- a/.gitattributes.template +++ /dev/null @@ -1,47 +0,0 @@ -# Default: let Git auto-detect text vs binary and normalize text to LF in the repo. -* text=auto - -# Shell scripts must be LF or bash breaks on Windows checkouts. -*.sh text eol=lf -*.bash text eol=lf - -# Windows shells require CRLF. -*.bat text eol=crlf -*.cmd text eol=crlf - -# PowerShell: pin CRLF for Windows-first tooling and signing compatibility. -*.ps1 text eol=crlf -*.psm1 text eol=crlf -*.psd1 text eol=crlf - -# Dotfiles and structured text: LF for cross-platform consistency. -.gitignore text eol=lf -.gitattributes text eol=lf -.editorconfig text eol=lf -*.yml text eol=lf -*.yaml text eol=lf -*.json text eol=lf -*.md text eol=lf - -# Source code: normalize to LF in repo (Git handles native checkout). -*.cs text -*.ts text -*.tsx text -*.js text -*.jsx text -*.py text -*.go text -*.rs text -*.java text - -# Common binaries -- never touch. -*.png binary -*.jpg binary -*.jpeg binary -*.gif binary -*.ico binary -*.pdf binary -*.zip binary -*.gz binary -*.dll binary -*.exe binary \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 65fbe4a..196bfe8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -43,6 +43,12 @@ If a `*.template` file is present but the corresponding consumer-owned file is not, copy the template (drop the `.template` suffix) and fill in the sections. `Pull-SDLC.ai.ps1` does this automatically on first sync. +For `.gitattributes` and `.gitignore`, run the repo-root script +`Initialize-GitDefaults.ps1` (alongside `Pull-SDLC.ai.ps1`) to compose +both files from per-language community templates plus a curated +PowerShell block. `Pull-SDLC.ai.ps1` prints a one-line hint pointing at +this script when either file is missing. + ## Project Overview This is a **C#/.NET** project. Discover the project's purpose, architecture, and full diff --git a/.github/templates/git-defaults/SOURCES.md b/.github/templates/git-defaults/SOURCES.md new file mode 100644 index 0000000..05757f3 --- /dev/null +++ b/.github/templates/git-defaults/SOURCES.md @@ -0,0 +1,65 @@ +# Bundled git-defaults snapshots + +Per-language templates composed by `Initialize-GitDefaults.ps1` into a +project's `.gitattributes` and `.gitignore`. + +## Pinned upstream sources + +| File | Upstream repo | Authority | Pinned SHA | +|------------------|--------------------------------|-------------------------------------------------|------------| +| `*.gitattributes`| `alexkaratarakis/gitattributes`| Community de facto -- no GitHub-org source exists | `fddc586cf0f10ec4485028d0d2dd6f73197a4258` | +| `*.gitignore` | `github/gitignore` | GitHub-org authoritative -- powers GitHub's UI picker | `dcc0fc7bc2b5ba480cf117ad1be31bafceeaff46` | + +The `github/gitattributes` repo does **not** exist (verified: +`gh api repos/github/gitattributes/commits/main` returns 404 at the time of +pinning). `alexkaratarakis/gitattributes` is the long-standing community +canonical source. + +## Files in this directory + +From `alexkaratarakis/gitattributes`: + +- `Common.gitattributes` -- baseline rules included for every language. +- `CSharp.gitattributes` -- C# language rules. +- `Web.gitattributes` -- TypeScript / web stack rules. + +From `github/gitignore`: + +- `VisualStudio.gitignore` -- C# / .NET / Visual Studio. +- `Node.gitignore` -- Node.js / TypeScript. +- `Global/Backup.gitignore` -- cross-platform editor backups. + +## Discoveries during initial bundle (issue #160) + +1. **`VisualStudio.gitattributes` does not exist** in + `alexkaratarakis/gitattributes` at the pinned SHA. ASP.NET therefore + inherits only from `CSharp.gitattributes` (no extra VS-specific layer). + The repo does ship `Web.gitattributes` (used for TypeScript). +2. **`PowerShell.gitattributes` exists upstream** but the script uses an + in-script curated block instead -- smaller surface, explicit + `linguist-language=PowerShell` hints, and signing-aware CRLF without + relying on upstream churn. Future option: switch to the upstream file + if it grows beyond the curated block's coverage. + +## Refresh procedure + +Run the bootstrap script with `-Refresh` (network fetch path; future +work). Manual procedure today: + +```powershell +$gaSha = '' +$giSha = '' +foreach ($f in 'Common','CSharp','Web') { + Invoke-WebRequest "https://raw.githubusercontent.com/alexkaratarakis/gitattributes/$gaSha/$f.gitattributes" ` + -OutFile ".github/templates/git-defaults/$f.gitattributes" -UseBasicParsing +} +foreach ($f in 'VisualStudio','Node') { + Invoke-WebRequest "https://raw.githubusercontent.com/github/gitignore/$giSha/$f.gitignore" ` + -OutFile ".github/templates/git-defaults/$f.gitignore" -UseBasicParsing +} +Invoke-WebRequest "https://raw.githubusercontent.com/github/gitignore/$giSha/Global/Backup.gitignore" ` + -OutFile ".github/templates/git-defaults/Global/Backup.gitignore" -UseBasicParsing +``` + +Then update the pinned SHAs in `Initialize-GitDefaults.ps1` (the +`$GitattributesRef` and `$GitignoreRef` defaults) and this file. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 10842e2..67bdc5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,12 @@ If a `*.template` file is present but the corresponding consumer-owned file is not, copy the template (drop the `.template` suffix) and fill in the sections. `Pull-SDLC.ai.ps1` does this automatically on first sync. +For `.gitattributes` and `.gitignore`, run the repo-root script +`Initialize-GitDefaults.ps1` (alongside `Pull-SDLC.ai.ps1`) to compose +both files from per-language community templates plus a curated +PowerShell block. `Pull-SDLC.ai.ps1` prints a one-line hint pointing at +this script when either file is missing. + ## GitHub Repository Determine the repository owner and name from the git remote: diff --git a/Initialize-GitDefaults.ps1 b/Initialize-GitDefaults.ps1 index 9cc55d6..4961912 100644 --- a/Initialize-GitDefaults.ps1 +++ b/Initialize-GitDefaults.ps1 @@ -61,6 +61,9 @@ # Re-fetch upstream templates at the pinned SHAs. #> [CmdletBinding(SupportsShouldProcess)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'User-facing CLI output matching the project-wide convention.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Internal New-*Content / New-*Header helpers are pure string builders despite the New- verb.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Initialize-GitDefaults and Resolve-GitDefaultsLanguages operate on a set; plural noun matches the conceptual data shape.')] param( [string[]] $Language, [switch] $IncludeGitignore, diff --git a/Pull-SDLC.ai.Tests.ps1 b/Pull-SDLC.ai.Tests.ps1 index b1eaa8f..fae4933 100644 --- a/Pull-SDLC.ai.Tests.ps1 +++ b/Pull-SDLC.ai.Tests.ps1 @@ -210,25 +210,8 @@ Describe 'Invoke-TemplateScaffold same-name scaffold from git ref (issue #156)' } } -Describe '.gitattributes.template bootstrap (issue #119)' { - BeforeAll { - $script:repoRoot = Resolve-Path (Join-Path $PSScriptRoot '.') | Select-Object -ExpandProperty Path - } - - It '$script:TemplateScaffoldMap maps .gitattributes.template -> .gitattributes' { - $script:TemplateScaffoldMap.Keys | Should -Contain '.gitattributes.template' - $script:TemplateScaffoldMap['.gitattributes.template'] | Should -Be '.gitattributes' - } - - It '.gitattributes.template exists at upstream repo root' { - Test-Path -LiteralPath (Join-Path $script:repoRoot '.gitattributes.template') | Should -BeTrue - } - - It '.gitattributes is on the always-local list (consumer-owned once placed)' { - Test-IsAlwaysLocalPath -Path '.gitattributes' | Should -BeTrue - } - - It 'creates consumer .gitattributes on first scaffold when none exists' { +Describe 'Invoke-TemplateScaffold (generic, issue #119 origin)' { + It 'creates the target file on first scaffold when none exists' { $src = Join-Path $TestDrive 'gabt-src' $dst = Join-Path $TestDrive 'gabt-dst1' New-Item -ItemType Directory -Path $src, $dst -Force | Out-Null @@ -239,7 +222,7 @@ Describe '.gitattributes.template bootstrap (issue #119)' { Test-Path (Join-Path $dst '.gitattributes') | Should -BeTrue } - It 'leaves an existing consumer .gitattributes untouched' { + It 'leaves an existing consumer target file untouched' { $src = Join-Path $TestDrive 'gabt-src2' $dst = Join-Path $TestDrive 'gabt-dst2' New-Item -ItemType Directory -Path $src, $dst -Force | Out-Null @@ -251,13 +234,8 @@ Describe '.gitattributes.template bootstrap (issue #119)' { (Get-Content (Join-Path $dst '.gitattributes') -Raw).Trim() | Should -Be 'CONSUMER_OWNED' } - It 'template content includes the baseline rules (LF for .sh, CRLF for .ps1, .png binary)' { - $body = Get-Content -LiteralPath (Join-Path $script:repoRoot '.gitattributes.template') -Raw - $body | Should -Match '(?m)^\*\.sh\s+text\s+eol=lf' - $body | Should -Match '(?m)^\*\.ps1\s+text\s+eol=crlf' - $body | Should -Match '(?m)^\*\.bat\s+text\s+eol=crlf' - $body | Should -Match '(?m)^\*\.png\s+binary' - $body | Should -Match '(?m)^\*\s+text=auto' + It '.gitattributes is on the always-local list (consumer-owned once placed)' { + Test-IsAlwaysLocalPath -Path '.gitattributes' | Should -BeTrue } } @@ -2202,3 +2180,51 @@ Describe 'Issue #148: bootstrap-on-main carve-out hygiene' { } } + +Describe 'Initialize-GitDefaults migration (issue #160)' { + It 'no longer scaffolds .gitattributes from a template' { + $script:TemplateScaffoldMap.Keys | Should -Not -Contain '.gitattributes.template' + $script:TemplateScaffoldMap.Values | Should -Not -Contain '.gitattributes' + } + + It 'no longer manages .gitattributes.template as upstream' { + $script:UpstreamManagedPaths | Should -Not -Contain '.gitattributes.template' + } + + It 'manages .github/templates/git-defaults/ as upstream' { + $script:UpstreamManagedPaths | Should -Contain '.github/templates/git-defaults/' + } + + It '.gitattributes remains consumer-owned (always-local)' { + $script:AlwaysLocalPaths | Should -Contain '.gitattributes' + } +} + +Describe 'Write-GitDefaultsHint (issue #160)' { + BeforeEach { + $script:hintRepo = Join-Path ([System.IO.Path]::GetTempPath()) ("hint-test-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $script:hintRepo | Out-Null + } + AfterEach { + Remove-Item -Recurse -Force $script:hintRepo -ErrorAction SilentlyContinue + } + + It 'prints the Initialize-GitDefaults hint exactly once when .gitattributes is missing' { + $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String + ([regex]::Matches($out, 'Initialize-GitDefaults\.ps1')).Count | Should -Be 1 + $out | Should -Match 'No \.gitattributes found' + } + + It 'mentions both upstream sources in the hint' { + $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String + $out | Should -Match 'alexkaratarakis/gitattributes' + $out | Should -Match 'github/gitignore' + } + + It 'prints nothing when both files already exist' { + New-Item -ItemType File -Path (Join-Path $script:hintRepo '.gitattributes') | Out-Null + New-Item -ItemType File -Path (Join-Path $script:hintRepo '.gitignore') | Out-Null + $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String + $out.Trim() | Should -BeNullOrEmpty + } +} diff --git a/Pull-SDLC.ai.ps1 b/Pull-SDLC.ai.ps1 index f396c61..261162b 100644 --- a/Pull-SDLC.ai.ps1 +++ b/Pull-SDLC.ai.ps1 @@ -149,7 +149,6 @@ $ErrorActionPreference = 'Stop' $script:TemplateScaffoldMap = [ordered]@{ '.github/instructions/project.instructions.md.template' = '.github/instructions/project.instructions.md' 'CLAUDE.project.md.template' = 'CLAUDE.project.md' - '.gitattributes.template' = '.gitattributes' 'README.md.template' = 'README.md' 'tasks/README.md' = 'tasks/README.md' } @@ -159,11 +158,11 @@ $script:TemplateScaffoldMap = [ordered]@{ $script:UpstreamManagedPaths = @( 'CLAUDE.md', '.github/copilot-instructions.md', - '.gitattributes.template', 'README.md.template', '.github/agents/', '.github/skills/', '.github/instructions/', + '.github/templates/git-defaults/', 'tasks/', # Meta-scripts: the bootstrap script the user downloads via `iwr` and # its siblings. Sync-managed so the user's local copy is reconciled @@ -1298,7 +1297,9 @@ function Write-NextStepsBanner { Source = 'bootstrap' or 'auto-bootstrap'). Lists the most valuable consumer file to edit first, the commit incantation, and -- when no `origin` is configured -- the `gh repo create` - hint for brand-new projects. + hint for brand-new projects. Also surfaces the one-line + Initialize-GitDefaults.ps1 hint when `.gitattributes` or + `.gitignore` is missing (issue #160). #> [CmdletBinding()] param( @@ -1331,6 +1332,32 @@ function Write-NextStepsBanner { Write-Host ' 3. If this is a brand-new project with no remote yet, create it on GitHub:' -ForegroundColor Cyan Write-Host ' gh repo create --source=. --public --push' -ForegroundColor Cyan } + + Write-GitDefaultsHint -RepoRoot $RepoRoot +} + +function Write-GitDefaultsHint { + <# + .SYNOPSIS + Print a one-line hint when the consumer is missing `.gitattributes` + and/or `.gitignore`, pointing them at Initialize-GitDefaults.ps1. + .DESCRIPTION + Issue #160. Replaces the legacy `.gitattributes.template` + first-sync scaffold with an explicit composable assembler. We do + NOT auto-invoke -- the consumer chooses languages. + #> + [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'User-facing CLI hint matching the surrounding banner convention.')] + param([Parameter(Mandatory)][string]$RepoRoot) + $missingAttrs = -not (Test-Path -LiteralPath (Join-Path $RepoRoot '.gitattributes')) + $missingIgnore = -not (Test-Path -LiteralPath (Join-Path $RepoRoot '.gitignore')) + if (-not ($missingAttrs -or $missingIgnore)) { return } + if ($missingAttrs) { + Write-Host 'No .gitattributes found. Run ./Initialize-GitDefaults.ps1 to scaffold one (uses alexkaratarakis/gitattributes + github/gitignore).' -ForegroundColor Yellow + } + elseif ($missingIgnore) { + Write-Host 'No .gitignore found. Run ./Initialize-GitDefaults.ps1 to scaffold one (uses alexkaratarakis/gitattributes + github/gitignore).' -ForegroundColor Yellow + } } function Invoke-SelfRefreshGate { diff --git a/README.md b/README.md index fb72c79..5ac1f8a 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,12 @@ What the script does: `.github/copilot-instructions.md`, `.github/agents/*`, `.github/skills/*`, generic `.github/instructions/*`, `.claude/*`). - Scaffolds consumer-owned files **only if missing** (`CLAUDE.project.md`, - `.github/instructions/project.instructions.md`, `.gitattributes`, `README.md`) from + `.github/instructions/project.instructions.md`, `README.md`) from their `*.template` counterparts. Existing copies are never overwritten. - Extends `.gitignore` with required entries; never replaces it. +- Prints a one-line hint pointing at `./Initialize-GitDefaults.ps1` when + `.gitattributes` or `.gitignore` is missing (does not auto-run -- the + consumer picks the languages). - Lands a `chore(sdlc): sync` commit and writes `.sdlc-ai-sync.json` recording the anchor for future incremental syncs. - Leaves your `origin` remote untouched. @@ -100,12 +103,22 @@ names if the bare name is missing: |---|---|---| | `.github/instructions/project.instructions.md.template` | `.github/instructions/project.instructions.md` | Project-specific conventions read by all agents | | `CLAUDE.project.md.template` | `CLAUDE.project.md` | Claude-specific orientation overrides | -| `.gitattributes.template` | `.gitattributes` | Recommended baseline (LF for `*.sh`, CRLF for `*.ps1` / `*.bat`) | | `README.md.template` | `README.md` | GitHub landing-page skeleton answering the five canonical README questions (what / why / start / help / who) | -Existing bare-name files are never overwritten. Once placed, all four are +Existing bare-name files are never overwritten. Once placed, all three are fully consumer-owned -- edit them freely. +`.gitattributes` and `.gitignore` are **not** scaffolded from a static +upstream template. Composing them is the job of the repo-root script +`Initialize-GitDefaults.ps1`, which combines per-language community +templates (`alexkaratarakis/gitattributes` + `github/gitignore`, both +pinned to specific SHAs) plus a curated PowerShell block. After a fresh +sync the next-steps banner prints a one-liner pointing you at the +script -- it never auto-runs because language selection is project- +specific. See the script's `Get-Help` and +`.github/templates/git-defaults/SOURCES.md` for the authority chain and +refresh procedure. + `README.md` uses the indirect (`.template` -> bare) pattern -- not the same-name pattern used for `tasks/README.md` below -- because the upstream's own root `README.md` describes IntelliSDLC.ai itself and From a908e071f512e1fbce84655692d90317dec82b68 Mon Sep 17 00:00:00 2001 From: Mark Michaelis Date: Sat, 30 May 2026 20:10:10 -0700 Subject: [PATCH 03/10] fix(git-defaults): address Copilot review feedback on #161 - UpstreamManagedPaths now lists Initialize-GitDefaults.ps1 and its Tests file so Pull-SDLC.ai will replace local edits on sync. - -Refresh hard-errors with a clear "not yet implemented" message instead of silently using bundled snapshots under a misleading header. Overriding -GitattributesRef/-GitignoreRef without -Refresh is also blocked to prevent header drift. - When -Language is omitted in an interactive host, a simple comma-separated picker now runs with detected languages offered as the default. New Get-GitDefaultsDetectedLanguages function scans the working tree for .csproj/.psm1/tsconfig.json/appsettings.json indicator files; ASP.NET requires both .csproj AND appsettings. - Backup-GitDefaultsFile now uses tick-resolution (100ns) suffixes on .bak collisions, with a uniqueness loop, so two backups in the same second can never overwrite each other. - Help block rewritten to honestly describe the implemented behaviour (no more reserved-future-feature wording in the synopsis). Tests: 36 Pester tests passing (was 28); new coverage for the four review threads. PSScriptAnalyzer clean. Co-authored-by: GitHub Copilot --- Initialize-GitDefaults.Tests.ps1 | 61 +++++++++++++ Initialize-GitDefaults.ps1 | 143 ++++++++++++++++++++++++++----- Pull-SDLC.ai.ps1 | 7 +- 3 files changed, 187 insertions(+), 24 deletions(-) diff --git a/Initialize-GitDefaults.Tests.ps1 b/Initialize-GitDefaults.Tests.ps1 index 035fe00..15014ab 100644 --- a/Initialize-GitDefaults.Tests.ps1 +++ b/Initialize-GitDefaults.Tests.ps1 @@ -197,4 +197,65 @@ Describe 'Initialize-GitDefaults (integration)' { $gi | Should -Match '(?m)^# === Node' $gi | Should -Match 'PSReadLine/ConsoleHost_history\.txt' } + + It '-Refresh hard-fails with a message naming the unimplemented fetch path (Copilot review #161)' { + { Initialize-GitDefaults -Language 'CSharp' -Refresh -Force -ErrorAction Stop } | + Should -Throw -ExpectedMessage '*Refresh*not yet implemented*' + Test-Path '.gitattributes' | Should -BeFalse + } + + It 'overriding pinned refs without -Refresh hard-fails to prevent header drift (Copilot review #161)' { + { Initialize-GitDefaults -Language 'CSharp' -GitattributesRef 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' -Force -ErrorAction Stop } | + Should -Throw -ExpectedMessage '*Refresh*' + } + + It 'tick-suffixed backups never collide on rapid successive runs (Copilot review #161)' { + Set-Content -Path '.gitattributes' -Value 'V1' -NoNewline + Initialize-GitDefaults -Language 'CSharp' -Force # .gitattributes.bak + Set-Content -Path '.gitattributes' -Value 'V2' -NoNewline + Initialize-GitDefaults -Language 'CSharp' -Force # .gitattributes.bak. + Set-Content -Path '.gitattributes' -Value 'V3' -NoNewline + Initialize-GitDefaults -Language 'CSharp' -Force # second .gitattributes.bak. + $baks = Get-ChildItem -Filter '.gitattributes.bak*' -Force + $baks.Count | Should -BeGreaterOrEqual 3 + # Each .bak* must have a distinct name + ($baks | Select-Object -ExpandProperty Name | Sort-Object -Unique).Count | Should -Be $baks.Count + } +} + +Describe 'Get-GitDefaultsDetectedLanguages (Copilot review #161)' { + BeforeEach { + $script:detRepo = Join-Path ([System.IO.Path]::GetTempPath()) ("detect-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $script:detRepo | Out-Null + } + AfterEach { + Remove-Item -Recurse -Force $script:detRepo -ErrorAction SilentlyContinue + } + + It 'detects CSharp from a .csproj file' { + Set-Content -Path (Join-Path $script:detRepo 'App.csproj') -Value '' + Get-GitDefaultsDetectedLanguages -Path $script:detRepo | Should -Contain 'CSharp' + } + + It 'detects PowerShell from a .psm1 file' { + Set-Content -Path (Join-Path $script:detRepo 'tool.psm1') -Value '# m' + Get-GitDefaultsDetectedLanguages -Path $script:detRepo | Should -Contain 'PowerShell' + } + + It 'detects TypeScript from a tsconfig.json' { + Set-Content -Path (Join-Path $script:detRepo 'tsconfig.json') -Value '{}' + Get-GitDefaultsDetectedLanguages -Path $script:detRepo | Should -Contain 'TypeScript' + } + + It 'detects ASP.NET only when both .csproj AND appsettings*.json are present' { + Set-Content -Path (Join-Path $script:detRepo 'App.csproj') -Value '' + Get-GitDefaultsDetectedLanguages -Path $script:detRepo | Should -Not -Contain 'ASP.NET' + Set-Content -Path (Join-Path $script:detRepo 'appsettings.json') -Value '{}' + Get-GitDefaultsDetectedLanguages -Path $script:detRepo | Should -Contain 'ASP.NET' + } + + It 'returns an empty result for a tree with no indicator files' { + $result = Get-GitDefaultsDetectedLanguages -Path $script:detRepo + @($result).Count | Should -Be 0 + } } \ No newline at end of file diff --git a/Initialize-GitDefaults.ps1 b/Initialize-GitDefaults.ps1 index 4961912..8787af6 100644 --- a/Initialize-GitDefaults.ps1 +++ b/Initialize-GitDefaults.ps1 @@ -17,14 +17,15 @@ commits/main` returns 404). Snapshots are bundled under `.github/templates/git-defaults/` at the pinned - SHAs and used by default. With -Refresh the script fetches fresh copies - over HTTPS and updates the local cache. + SHAs and used by default. The `-Refresh` switch is reserved for a future + network-fetch path and currently hard-errors (see .PARAMETER Refresh). .PARAMETER Language Languages to include. Validated against the supported set. ASP.NET implies - CSharp. When omitted in an interactive host a multi-select picker runs with - languages detected from the working tree pre-selected. Non-interactive + - omitted = abort with instructions. + CSharp. When omitted in an interactive host a simple comma-separated picker + runs with languages detected from the working tree (via + Get-GitDefaultsDetectedLanguages) offered as the default. Non-interactive + hosts and an omitted value abort with the list of supported languages. .PARAMETER IncludeGitignore Compose `.gitignore`. Default: $true. @@ -33,21 +34,26 @@ Compose `.gitattributes`. Default: $true. .PARAMETER GitattributesRef - Git SHA in alexkaratarakis/gitattributes to fetch templates from when - -Refresh is supplied. Pinned default. + Git SHA in alexkaratarakis/gitattributes the bundled snapshots are pinned to. + Overriding this only makes sense alongside -Refresh (network fetch), which + is not yet implemented; passing a non-default value without -Refresh hard- + errors to prevent header drift. .PARAMETER GitignoreRef - Git SHA in github/gitignore to fetch templates from when -Refresh is - supplied. Pinned default. + Git SHA in github/gitignore the bundled snapshots are pinned to. Same + override semantics as -GitattributesRef. .PARAMETER Refresh - Bypass bundled snapshots; fetch fresh copies from GitHub over HTTPS and - update the local cache. Use to validate against latest upstream. + RESERVED. Intended to bypass bundled snapshots and fetch fresh copies from + GitHub over HTTPS at the requested refs. NOT YET IMPLEMENTED in this + release; passing -Refresh hard-errors. Omit -Refresh to compose from the + bundled snapshots at the pinned SHAs. .PARAMETER Force Overwrite existing `.gitattributes` / `.gitignore` after backing the - original up to `.bak` (with timestamp suffix if `.bak` already - exists). Without -Force the script aborts if the target exists. + original up to `.bak` (with a unique tick-resolution suffix if + `.bak` already exists). Without -Force the script aborts if the target + exists. .EXAMPLE ./Initialize-GitDefaults.ps1 -Language CSharp,PowerShell -Force @@ -57,8 +63,9 @@ # ASP.NET expands to {ASP.NET, CSharp}. .EXAMPLE - ./Initialize-GitDefaults.ps1 -Language CSharp -Refresh -Force - # Re-fetch upstream templates at the pinned SHAs. + ./Initialize-GitDefaults.ps1 + # Interactive picker: prompts with heuristically-detected languages as + # the default. Non-interactive hosts must pass -Language explicitly. #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'User-facing CLI output matching the project-wide convention.')] @@ -335,7 +342,9 @@ function Test-GitDefaultsRepo { function Backup-GitDefaultsFile { <# .SYNOPSIS - Copy an existing file to `.bak`, suffixing a timestamp on collision. + Copy an existing file to `.bak`, falling back to a + ticks-suffixed name on collision so re-runs within the same + second never overwrite an earlier backup. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([string])] @@ -345,8 +354,13 @@ function Backup-GitDefaultsFile { if (-not (Test-Path -LiteralPath $Path)) { return $null } $bak = "$Path.bak" if (Test-Path -LiteralPath $bak) { - $ts = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ') - $bak = "$Path.bak.$ts" + # Tick-resolution (100 ns) instead of seconds so two backups in + # the same second still get unique names. Loop guards against an + # absurdly fast clock or filesystem timestamp collision. + do { + $ticks = [DateTime]::UtcNow.Ticks + $bak = "$Path.bak.$ticks" + } while (Test-Path -LiteralPath $bak) } if ($PSCmdlet.ShouldProcess($Path, "Backup to $bak")) { Copy-Item -LiteralPath $Path -Destination $bak -Force @@ -387,6 +401,73 @@ function Write-GitDefaultsFile { } } +function Get-GitDefaultsDetectedLanguages { + <# + .SYNOPSIS + Heuristically detect candidate languages by scanning the current + working tree for indicator files. + .DESCRIPTION + Conservative: only matches things we have explicit templates for. + ASP.NET requires both a .csproj and an appsettings.json (otherwise + the consumer is a plain console / library project and CSharp alone + is the right pre-selection). + #> + [CmdletBinding()] + [OutputType([string[]])] + param([string] $Path = (Get-Location).Path) + + $detected = [System.Collections.Generic.HashSet[string]]::new() + $hasCsproj = @(Get-ChildItem -Path $Path -Recurse -File -Include '*.csproj','*.sln' -ErrorAction SilentlyContinue -Depth 3).Count -gt 0 + $hasPs = @(Get-ChildItem -Path $Path -Recurse -File -Include '*.ps1','*.psm1','*.psd1' -ErrorAction SilentlyContinue -Depth 3).Count -gt 0 + $hasTs = @(Get-ChildItem -Path $Path -Recurse -File -Include 'tsconfig.json','package.json' -ErrorAction SilentlyContinue -Depth 3).Count -gt 0 + $hasAppSets = @(Get-ChildItem -Path $Path -Recurse -File -Filter 'appsettings*.json' -ErrorAction SilentlyContinue -Depth 3).Count -gt 0 + + if ($hasCsproj) { [void]$detected.Add('CSharp') } + if ($hasPs) { [void]$detected.Add('PowerShell') } + if ($hasTs) { [void]$detected.Add('TypeScript') } + if ($hasCsproj -and $hasAppSets) { [void]$detected.Add('ASP.NET') } + + return @($detected | Sort-Object) +} + +function Read-GitDefaultsLanguageSelection { + <# + .SYNOPSIS + Prompt the user to pick languages when -Language was not supplied. + .DESCRIPTION + Used only when the host is interactive (Read-Host available). Pre- + selects languages detected via Get-GitDefaultsDetectedLanguages. + Non-interactive hosts return $null so the caller can throw with the + explicit-parameter guidance. + #> + [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive picker output.')] + [OutputType([string[]])] + param() + + if (-not [Environment]::UserInteractive -or $Host.Name -eq 'Default Host') { + return $null + } + + $detected = Get-GitDefaultsDetectedLanguages + $supported = @($script:GitDefaultsLanguages.Keys | Sort-Object) + + Write-Host '' + Write-Host 'Select languages to include (press Enter to accept the detected default):' -ForegroundColor Cyan + if ($detected.Count -gt 0) { + Write-Host (" Detected: {0}" -f ($detected -join ', ')) -ForegroundColor Cyan + } else { + Write-Host ' Detected: (none -- nothing in this tree matches the heuristics)' -ForegroundColor DarkYellow + } + Write-Host (" Supported: {0}" -f ($supported -join ', ')) -ForegroundColor DarkGray + $defaultCsv = if ($detected.Count -gt 0) { $detected -join ',' } else { '' } + $reply = Read-Host -Prompt "Languages (comma-separated) [$defaultCsv]" + if ([string]::IsNullOrWhiteSpace($reply)) { + return $detected + } + return @(($reply -split ',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }) +} + function Initialize-GitDefaults { <# .SYNOPSIS @@ -422,13 +503,29 @@ function Initialize-GitDefaults { throw "Current directory is not a git repository. Run 'git init' first or cd into a repo." } - if (-not $Language -or $Language.Count -eq 0) { - throw 'No -Language supplied. Pass -Language with one or more of: ' + - (($script:GitDefaultsLanguages.Keys | Sort-Object) -join ', ') + '.' + if ($Refresh) { + # Honest stance: -Refresh is reserved for the network-fetch path, + # which is not implemented in this release. Hard-fail rather than + # silently emitting bundled content under a "refreshed" header + # (which would mislead consumers about the actual source). + throw '-Refresh (network fetch from upstream at -GitattributesRef/-GitignoreRef) is not yet implemented. Omit -Refresh to compose from the bundled snapshots, or open an issue to prioritise the fetch path.' } - if ($Refresh) { - Write-Warning '-Refresh fetch path not implemented in this release; using bundled snapshots.' + # Guard against header drift when consumers override the pinned refs: + # the bundled snapshots are pinned to specific SHAs, so we cannot + # honestly claim a different ref in the header without fetching. + $defaultGitattrRef = 'fddc586cf0f10ec4485028d0d2dd6f73197a4258' + $defaultGitignoreRef = 'dcc0fc7bc2b5ba480cf117ad1be31bafceeaff46' + if ($GitattributesRef -ne $defaultGitattrRef -or $GitignoreRef -ne $defaultGitignoreRef) { + throw "Overriding -GitattributesRef or -GitignoreRef requires -Refresh to actually fetch that ref, and -Refresh is not yet implemented. Use the pinned defaults ($defaultGitattrRef / $defaultGitignoreRef)." + } + + if (-not $Language -or $Language.Count -eq 0) { + $Language = Read-GitDefaultsLanguageSelection + if (-not $Language -or $Language.Count -eq 0) { + throw 'No -Language supplied. Pass -Language with one or more of: ' + + (($script:GitDefaultsLanguages.Keys | Sort-Object) -join ', ') + '.' + } } $expanded = Resolve-GitDefaultsLanguages -Language $Language diff --git a/Pull-SDLC.ai.ps1 b/Pull-SDLC.ai.ps1 index 261162b..62e0bca 100644 --- a/Pull-SDLC.ai.ps1 +++ b/Pull-SDLC.ai.ps1 @@ -173,7 +173,12 @@ $script:UpstreamManagedPaths = @( 'Pull-SDLC.ai.Tests.ps1', 'Cleanup-Worktree.ps1', 'Consolidate-Tasks.ps1', - 'Consolidate-Tasks.Tests.ps1' + 'Consolidate-Tasks.Tests.ps1', + # Issue #160: the composable .gitattributes/.gitignore assembler the + # post-sync hint points at. Without this entry the hint points at a + # script that does not exist in fresh consumer worktrees. + 'Initialize-GitDefaults.ps1', + 'Initialize-GitDefaults.Tests.ps1' ) # Subset of UpstreamManagedPaths whose mere presence in the consumer's working From 284b6c9b9a28a05f9ab6c3f1cb121d4f77a04a1a Mon Sep 17 00:00:00 2001 From: Mark Michaelis Date: Sat, 30 May 2026 20:21:05 -0700 Subject: [PATCH 04/10] fix(git-defaults): address Copilot review round 2 on #161 Seven new findings, all addressed: - Preflight existence check: Initialize-GitDefaults now tests for ALL requested target files before writing ANY of them, so when -Force is omitted and (say) only .gitignore exists, the script aborts without leaving a freshly-written .gitattributes behind. Previously the first write succeeded and the second threw, producing a partial result. - Curated PowerShell block: the in-script comment and the generated file header both said "no upstream template" but SOURCES.md documents that PowerShell.gitattributes DOES exist upstream and is intentionally overridden. Comments + header now describe the override honestly and point at SOURCES.md. - Generated-file footer no longer tells consumers to "re-run to refresh" -- that wording implied -Refresh works, which it does not. Footer now reads "regenerate or add languages". - Get-GitDefaultsTemplateContent error no longer suggests -Refresh for a missing bundled snapshot; it suggests re-running Pull-SDLC.ai instead (which is what actually restores .github/templates/). - Global/Backup.gitignore was bundled but never emitted; .gitignore now always includes the cross-platform Backup section as its final block. - Write-GitDefaultsHint (Pull-SDLC.ai.ps1) now branches three ways -- both missing / only .gitattributes missing / only .gitignore missing -- and gives the appropriate single-file command (-IncludeGitignore:$false or -IncludeGitattributes:$false) so the hint does not steer users into the preflight abort. Tests: 39 Pester tests on Initialize-GitDefaults (added 5); Write-GitDefaultsHint suite expanded from 3 to 5 cases. All targeted suites green, PSScriptAnalyzer baseline unchanged. Co-authored-by: GitHub Copilot --- Initialize-GitDefaults.Tests.ps1 | 33 ++++++++++++++++++++++++++--- Initialize-GitDefaults.ps1 | 36 +++++++++++++++++++++++++++----- Pull-SDLC.ai.Tests.ps1 | 29 ++++++++++++++++++------- Pull-SDLC.ai.ps1 | 11 ++++++---- 4 files changed, 90 insertions(+), 19 deletions(-) diff --git a/Initialize-GitDefaults.Tests.ps1 b/Initialize-GitDefaults.Tests.ps1 index 15014ab..b8d25f1 100644 --- a/Initialize-GitDefaults.Tests.ps1 +++ b/Initialize-GitDefaults.Tests.ps1 @@ -254,8 +254,35 @@ Describe 'Get-GitDefaultsDetectedLanguages (Copilot review #161)' { Get-GitDefaultsDetectedLanguages -Path $script:detRepo | Should -Contain 'ASP.NET' } - It 'returns an empty result for a tree with no indicator files' { - $result = Get-GitDefaultsDetectedLanguages -Path $script:detRepo - @($result).Count | Should -Be 0 + It 'always includes the cross-platform Global/Backup section (Copilot review #161 round 2)' { + $content = New-GitIgnoreContent -Language @('CSharp') + $content | Should -Match '(?m)^# === Global/Backup' + } + + It 'includes Backup section even when only PowerShell (no upstream gitignore) is selected' { + $content = New-GitIgnoreContent -Language @('PowerShell') + $content | Should -Match '(?m)^# === Global/Backup' + } + + It 'header mentions the curated PowerShell block as an intentional override (Copilot review #161 round 2)' { + $content = New-GitAttributesContent -Language @('PowerShell','CSharp') + $content | Should -Match 'intentional override' + $content | Should -Not -Match 'no upstream template' + } + + It 'preflight aborts BEFORE writing any file when one of multiple targets exists (Copilot review #161 round 2)' { + $repo = Join-Path ([System.IO.Path]::GetTempPath()) ("pf-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $repo | Out-Null + & git -C $repo init --quiet 2>&1 | Out-Null + Push-Location $repo + try { + Set-Content -Path '.gitignore' -Value 'pre-existing' -NoNewline + { Initialize-GitDefaults -Language 'CSharp' -ErrorAction Stop } | Should -Throw -ExpectedMessage '*already exists*' + Test-Path '.gitattributes' | Should -BeFalse + (Get-Content '.gitignore' -Raw) | Should -Be 'pre-existing' + } finally { + Pop-Location + Remove-Item -Recurse -Force $repo -ErrorAction SilentlyContinue + } } } \ No newline at end of file diff --git a/Initialize-GitDefaults.ps1 b/Initialize-GitDefaults.ps1 index 8787af6..0452d11 100644 --- a/Initialize-GitDefaults.ps1 +++ b/Initialize-GitDefaults.ps1 @@ -93,7 +93,9 @@ $script:GitDefaultsLanguages = [ordered]@{ } $script:CuratedPowerShellGitattributes = @' -# PowerShell (no upstream template; rules curated locally) +# PowerShell (curated in-script; intentionally overrides upstream +# PowerShell.gitattributes -- smaller surface, signing-aware, explicit +# linguist hints. See .github/templates/git-defaults/SOURCES.md.) *.ps1 text eol=crlf *.psm1 text eol=crlf *.psd1 text eol=crlf @@ -105,7 +107,8 @@ $script:CuratedPowerShellGitattributes = @' '@ $script:CuratedPowerShellGitignore = @' -# PowerShell (no upstream template; rules curated locally) +# PowerShell (curated in-script; no upstream PowerShell.gitignore exists +# in github/gitignore at the pinned SHA. See SOURCES.md.) PSReadLine/ConsoleHost_history.txt *.psproj.user '@ @@ -175,7 +178,7 @@ function Get-GitDefaultsTemplateContent { ) $path = Join-Path (Get-GitDefaultsTemplateRoot) $FileName if (-not (Test-Path -LiteralPath $path)) { - throw "Bundled template snapshot not found: $path. Re-run with -Refresh to fetch from upstream." + throw "Bundled template snapshot not found: $path. Re-pull IntelliSDLC.ai (Pull-SDLC.ai.ps1) to restore the .github/templates/git-defaults/ snapshots." } return (Get-Content -LiteralPath $path -Raw) } @@ -213,9 +216,9 @@ function New-GitDefaultsHeader { } } if ($hasPs) { - [void]$lines.Add('# Curated additions: PowerShell (no upstream template)') + [void]$lines.Add('# Curated additions: PowerShell (intentional override of upstream; see SOURCES.md)') } - [void]$lines.Add('# Re-run Initialize-GitDefaults.ps1 to refresh or add languages.') + [void]$lines.Add('# Re-run Initialize-GitDefaults.ps1 -Language ... -Force to regenerate or add languages.') [void]$lines.Add('') return ($lines -join "`n") } @@ -300,6 +303,13 @@ function New-GitIgnoreContent { [void]$upstreamSections.Add($section) } } + # Always include the cross-platform Backup snapshot. It is bundled + # under .github/templates/git-defaults/Global/ for exactly this + # purpose, and is language-independent (editor backups, OS junk). + $backupFile = 'Global/Backup.gitignore' + if ($seenFiles.Add($backupFile)) { + [void]$upstreamSections.Add('Global/Backup') + } $header = New-GitDefaultsHeader -Kind 'gitignore' -Language $expanded -UpstreamSections $upstreamSections @@ -318,6 +328,11 @@ function New-GitIgnoreContent { [void]$body.Add($script:CuratedPowerShellGitignore) } } + # Cross-platform editor/OS backup patterns, appended once. + if ($emitted.Add($backupFile)) { + [void]$body.Add((New-GitDefaultsSectionDivider -Section 'Global/Backup' -SourceLabel $backupFile)) + [void]$body.Add((Get-GitDefaultsTemplateContent -FileName $backupFile)) + } return ($body -join "`n") } @@ -531,6 +546,17 @@ function Initialize-GitDefaults { $expanded = Resolve-GitDefaultsLanguages -Language $Language Write-Verbose ("Resolved languages: {0}" -f ($expanded -join ', ')) + # Preflight: check existence of ALL requested targets before writing + # any file, so we never leave a partial result when -Force is omitted. + if (-not $Force) { + $existing = @() + if ($IncludeGitattributes -and (Test-Path -LiteralPath '.gitattributes')) { $existing += '.gitattributes' } + if ($IncludeGitignore -and (Test-Path -LiteralPath '.gitignore')) { $existing += '.gitignore' } + if ($existing.Count -gt 0) { + throw ("Aborting: {0} already exists. Re-run with -Force to back up and overwrite." -f ($existing -join ', ')) + } + } + if ($IncludeGitattributes) { $content = New-GitAttributesContent -Language $expanded Write-GitDefaultsFile -Path '.gitattributes' -Content $content -Force:$Force diff --git a/Pull-SDLC.ai.Tests.ps1 b/Pull-SDLC.ai.Tests.ps1 index fae4933..19e0046 100644 --- a/Pull-SDLC.ai.Tests.ps1 +++ b/Pull-SDLC.ai.Tests.ps1 @@ -2209,13 +2209,28 @@ Describe 'Write-GitDefaultsHint (issue #160)' { Remove-Item -Recurse -Force $script:hintRepo -ErrorAction SilentlyContinue } - It 'prints the Initialize-GitDefaults hint exactly once when .gitattributes is missing' { - $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String - ([regex]::Matches($out, 'Initialize-GitDefaults\.ps1')).Count | Should -Be 1 - $out | Should -Match 'No \.gitattributes found' - } - - It 'mentions both upstream sources in the hint' { + It 'prints the both-missing hint exactly once when neither file exists' { + $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String + ([regex]::Matches($out, 'Initialize-GitDefaults\.ps1')).Count | Should -Be 1 + $out | Should -Match 'No \.gitattributes or \.gitignore found' + } + + It 'prints the gitattributes-only hint with -IncludeGitignore:$false when only .gitattributes is missing (Copilot review #161 round 2)' { + New-Item -ItemType File -Path (Join-Path $script:hintRepo '.gitignore') | Out-Null + $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String + $out | Should -Match 'No \.gitattributes found' + $out | Should -Match '-IncludeGitignore:\$false' + $out | Should -Match 'your existing \.gitignore is untouched' + } + + It 'prints the gitignore-only hint with -IncludeGitattributes:$false when only .gitignore is missing (Copilot review #161 round 2)' { + New-Item -ItemType File -Path (Join-Path $script:hintRepo '.gitattributes') | Out-Null + $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String + $out | Should -Match 'No \.gitignore found' + $out | Should -Match '-IncludeGitattributes:\$false' + } + + It 'mentions both upstream sources in the both-missing hint' { $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String $out | Should -Match 'alexkaratarakis/gitattributes' $out | Should -Match 'github/gitignore' diff --git a/Pull-SDLC.ai.ps1 b/Pull-SDLC.ai.ps1 index 62e0bca..8012bd1 100644 --- a/Pull-SDLC.ai.ps1 +++ b/Pull-SDLC.ai.ps1 @@ -1357,11 +1357,14 @@ function Write-GitDefaultsHint { $missingAttrs = -not (Test-Path -LiteralPath (Join-Path $RepoRoot '.gitattributes')) $missingIgnore = -not (Test-Path -LiteralPath (Join-Path $RepoRoot '.gitignore')) if (-not ($missingAttrs -or $missingIgnore)) { return } - if ($missingAttrs) { - Write-Host 'No .gitattributes found. Run ./Initialize-GitDefaults.ps1 to scaffold one (uses alexkaratarakis/gitattributes + github/gitignore).' -ForegroundColor Yellow + if ($missingAttrs -and $missingIgnore) { + Write-Host 'No .gitattributes or .gitignore found. Run ./Initialize-GitDefaults.ps1 -Language -Force to scaffold both (uses alexkaratarakis/gitattributes + github/gitignore at pinned SHAs).' -ForegroundColor Yellow } - elseif ($missingIgnore) { - Write-Host 'No .gitignore found. Run ./Initialize-GitDefaults.ps1 to scaffold one (uses alexkaratarakis/gitattributes + github/gitignore).' -ForegroundColor Yellow + elseif ($missingAttrs) { + Write-Host 'No .gitattributes found. Run ./Initialize-GitDefaults.ps1 -Language -IncludeGitignore:$false -Force (your existing .gitignore is untouched).' -ForegroundColor Yellow + } + else { + Write-Host 'No .gitignore found. Run ./Initialize-GitDefaults.ps1 -Language -IncludeGitattributes:$false -Force (your existing .gitattributes is untouched).' -ForegroundColor Yellow } } From 1ca3935f8e0f69d0b021da3fd58022a12d6dfc01 Mon Sep 17 00:00:00 2001 From: Mark Michaelis Date: Sat, 30 May 2026 20:30:18 -0700 Subject: [PATCH 05/10] fix(git-defaults): address Copilot review round 3 on #161 Three findings, all addressed: - Kind-aware curated PowerShell header: the previous "intentional override of upstream" line was emitted for both generated files, but it is only honest for .gitattributes (where upstream DOES ship a PowerShell.gitattributes that we replace). For .gitignore the header now says "no upstream PowerShell.gitignore exists in github/gitignore", matching the in-script comment on the curated block and SOURCES.md. - Removed dead GiboName metadata from $script:GitDefaultsLanguages. No code reads it; carrying it invited drift between the registry and actual composition behaviour. Re-introduce it if/when the network-fetch path is wired in. - New behaviour-first tests for the no-Language branch using Pester Mock to simulate both the non-interactive picker (returns null, Initialize-GitDefaults throws with the supported-language list) and the interactive picker (returns CSharp+PowerShell, full generation proceeds end-to-end). Plus an additional kitchen-sink test that exercises every heuristic in Get-GitDefaultsDetectedLanguages. Tests: 45 Pester (added 6); all green. PSScriptAnalyzer clean. Co-authored-by: GitHub Copilot --- Initialize-GitDefaults.Tests.ps1 | 88 +++++++++++++++++++++++++++++++- Initialize-GitDefaults.ps1 | 18 +++++-- 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/Initialize-GitDefaults.Tests.ps1 b/Initialize-GitDefaults.Tests.ps1 index b8d25f1..1b7b57a 100644 --- a/Initialize-GitDefaults.Tests.ps1 +++ b/Initialize-GitDefaults.Tests.ps1 @@ -285,4 +285,90 @@ Describe 'Get-GitDefaultsDetectedLanguages (Copilot review #161)' { Remove-Item -Recurse -Force $repo -ErrorAction SilentlyContinue } } -} \ No newline at end of file +} + +Describe 'Copilot review #161 round 3: no-Language code path' { + Context 'when the picker declines (non-interactive host simulated via Mock)' { + BeforeEach { + Mock -CommandName 'Read-GitDefaultsLanguageSelection' -MockWith { return $null } + $script:nlRepo = Join-Path ([System.IO.Path]::GetTempPath()) ("nl-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $script:nlRepo | Out-Null + & git -C $script:nlRepo init --quiet 2>&1 | Out-Null + Push-Location $script:nlRepo + } + AfterEach { + Pop-Location + Remove-Item -Recurse -Force $script:nlRepo -ErrorAction SilentlyContinue + } + + It 'throws a helpful error naming the supported languages when -Language is omitted' { + { Initialize-GitDefaults -ErrorAction Stop } | Should -Throw -ExpectedMessage '*No -Language supplied*' + Test-Path '.gitattributes' | Should -BeFalse + Test-Path '.gitignore' | Should -BeFalse + Assert-MockCalled -CommandName 'Read-GitDefaultsLanguageSelection' -Times 1 -Scope It + } + + It 'error message enumerates all supported languages so the user knows valid inputs' { + { Initialize-GitDefaults -ErrorAction Stop } | Should -Throw -ExpectedMessage '*CSharp*' + { Initialize-GitDefaults -ErrorAction Stop } | Should -Throw -ExpectedMessage '*PowerShell*' + { Initialize-GitDefaults -ErrorAction Stop } | Should -Throw -ExpectedMessage '*TypeScript*' + { Initialize-GitDefaults -ErrorAction Stop } | Should -Throw -ExpectedMessage '*ASP.NET*' + } + } + + Context 'when the picker returns languages (interactive host simulated via Mock)' { + BeforeEach { + Mock -CommandName 'Read-GitDefaultsLanguageSelection' -MockWith { return @('CSharp','PowerShell') } + $script:nlRepo = Join-Path ([System.IO.Path]::GetTempPath()) ("nl2-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $script:nlRepo | Out-Null + & git -C $script:nlRepo init --quiet 2>&1 | Out-Null + Push-Location $script:nlRepo + } + AfterEach { + Pop-Location + Remove-Item -Recurse -Force $script:nlRepo -ErrorAction SilentlyContinue + } + + It 'composes generated files from the picker result without -Language being passed' { + Initialize-GitDefaults -Force + Assert-MockCalled -CommandName 'Read-GitDefaultsLanguageSelection' -Times 1 -Scope It + (Get-Content '.gitattributes' -Raw) | Should -Match '(?m)^# === CSharp' + (Get-Content '.gitattributes' -Raw) | Should -Match '\*\.ps1\s+text eol=crlf' + (Get-Content '.gitignore' -Raw) | Should -Match '(?m)^# === VisualStudio' + (Get-Content '.gitignore' -Raw) | Should -Match '(?m)^# === Global/Backup' + } + } + + Context 'detection heuristics feed the picker default' { + It 'pre-selects every supported language in a kitchen-sink tree' { + $repo = Join-Path ([System.IO.Path]::GetTempPath()) ("ks-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $repo | Out-Null + try { + Set-Content -Path (Join-Path $repo 'App.csproj') -Value '' + Set-Content -Path (Join-Path $repo 'appsettings.json') -Value '{}' + Set-Content -Path (Join-Path $repo 'tsconfig.json') -Value '{}' + Set-Content -Path (Join-Path $repo 'tool.psm1') -Value '# m' + $detected = Get-GitDefaultsDetectedLanguages -Path $repo + $detected | Should -Contain 'CSharp' + $detected | Should -Contain 'PowerShell' + $detected | Should -Contain 'TypeScript' + $detected | Should -Contain 'ASP.NET' + } finally { + Remove-Item -Recurse -Force $repo -ErrorAction SilentlyContinue + } + } + } +} + +Describe 'Copilot review #161 round 3: kind-aware curated PowerShell header' { + It 'gitattributes header attributes the PowerShell block as an intentional override of upstream' { + $content = New-GitAttributesContent -Language @('PowerShell','CSharp') + $content | Should -Match 'intentional override of upstream PowerShell\.gitattributes' + } + + It 'gitignore header attributes the PowerShell block to absence of upstream PowerShell.gitignore' { + $content = New-GitIgnoreContent -Language @('PowerShell','CSharp') + $content | Should -Match 'no upstream PowerShell\.gitignore exists' + $content | Should -Not -Match 'intentional override' + } +} diff --git a/Initialize-GitDefaults.ps1 b/Initialize-GitDefaults.ps1 index 0452d11..6fc7cbb 100644 --- a/Initialize-GitDefaults.ps1 +++ b/Initialize-GitDefaults.ps1 @@ -86,10 +86,10 @@ param( # Canonical language registry. Order of `Sections` controls divider naming. # `Deps` lists languages this one depends on (transitively expanded). $script:GitDefaultsLanguages = [ordered]@{ - 'CSharp' = @{ Canonical = 'CSharp'; Deps = @(); GitattrFile = 'CSharp.gitattributes'; GitignoreFile = 'VisualStudio.gitignore'; GiboName = 'visualstudio' } - 'PowerShell' = @{ Canonical = 'PowerShell'; Deps = @(); GitattrFile = $null; GitignoreFile = $null; GiboName = $null } - 'TypeScript' = @{ Canonical = 'TypeScript'; Deps = @(); GitattrFile = 'Web.gitattributes'; GitignoreFile = 'Node.gitignore'; GiboName = 'node' } - 'ASP.NET' = @{ Canonical = 'ASP.NET'; Deps = @('CSharp'); GitattrFile = $null; GitignoreFile = $null; GiboName = 'visualstudio' } + 'CSharp' = @{ Canonical = 'CSharp'; Deps = @(); GitattrFile = 'CSharp.gitattributes'; GitignoreFile = 'VisualStudio.gitignore' } + 'PowerShell' = @{ Canonical = 'PowerShell'; Deps = @(); GitattrFile = $null; GitignoreFile = $null } + 'TypeScript' = @{ Canonical = 'TypeScript'; Deps = @(); GitattrFile = 'Web.gitattributes'; GitignoreFile = 'Node.gitignore' } + 'ASP.NET' = @{ Canonical = 'ASP.NET'; Deps = @('CSharp'); GitattrFile = $null; GitignoreFile = $null } } $script:CuratedPowerShellGitattributes = @' @@ -216,7 +216,15 @@ function New-GitDefaultsHeader { } } if ($hasPs) { - [void]$lines.Add('# Curated additions: PowerShell (intentional override of upstream; see SOURCES.md)') + if ($Kind -eq 'gitattributes') { + # alexkaratarakis/gitattributes DOES ship PowerShell.gitattributes; + # we intentionally override it with a smaller curated block. + [void]$lines.Add('# Curated additions: PowerShell (intentional override of upstream PowerShell.gitattributes; see SOURCES.md)') + } else { + # github/gitignore does NOT ship a PowerShell.gitignore at the + # pinned SHA; the curated block fills that gap. + [void]$lines.Add('# Curated additions: PowerShell (no upstream PowerShell.gitignore exists in github/gitignore; see SOURCES.md)') + } } [void]$lines.Add('# Re-run Initialize-GitDefaults.ps1 -Language ... -Force to regenerate or add languages.') [void]$lines.Add('') From 900839cb385ad85d1c7d59df1737e611b9cf6cf7 Mon Sep 17 00:00:00 2001 From: Mark Michaelis Date: Sat, 30 May 2026 20:44:41 -0700 Subject: [PATCH 06/10] fix(git-defaults): address Copilot review round 4 on #161 Four findings, all addressed: - Strict mode: Initialize-GitDefaults.ps1 now sets Set-StrictMode -Version Latest and $ErrorActionPreference = 'Stop' immediately after the param block, matching the convention used by Pull-SDLC.ai.ps1, Cleanup-Worktree.ps1, and Consolidate-Tasks.ps1. Catches undefined variables and non-terminating cmdlet errors that would previously continue silently while composing consumer files. - -Refresh is now implemented (previously hard-errored). New helpers: * Get-GitDefaultsCacheRoot -- platform-aware cache dir ($LOCALAPPDATA on Windows, $XDG_CACHE_HOME / $HOME/.cache on Unix, falling back to temp). * Resolve-GitDefaultsSourceRepo -- maps a template file name to (Repo, Ref) using the appropriate ref param. * Get-GitDefaultsRefreshedContent -- fetches from raw.githubusercontent.com///, writes the response into the cache directory, and returns the content. If the fetch fails AND a previously-cached copy exists, falls back to the cache with a warning. If both fail, throws with a clear message naming the URL and cache path. Get-GitDefaultsTemplateContent now accepts -Refresh and routes through Get-GitDefaultsRefreshedContent in that case. The compose functions thread -Refresh / -GitattributesRef / -GitignoreRef through to each fetch call. Generated-file headers now include a "Source mode: bundled snapshot" or "Source mode: fetched from upstream" line so consumers can tell at a glance which path was used. The curated PowerShell block is always in-script and is unaffected by -Refresh. - gibo non-dependency: SOURCES.md now has a "Why not gibo?" section explaining why Initialize-GitDefaults talks directly to raw.githubusercontent.com instead of shelling out to the gibo helper (same upstream, but with SHA pinning + cache-fallback that gibo does not offer). Closes the spec-vs-implementation gap raised by the reviewer. - Tests for one-file invocations: -IncludeGitignore:$false and -IncludeGitattributes:$false each get their own behaviour-first test proving the existing untouched-file is preserved (no .bak written, content byte-identical), the requested file is created, and the omitted file is not silently created in the empty-tree case. Plus 6 new tests exercising the -Refresh code path end-to- end via mocked Invoke-WebRequest: verifies the fetched URLs (CSharp set), verifies sentinel content lands in the generated files, verifies the "fetched" / "bundled" header label, verifies override of -GitattributesRef changes the fetch URL, verifies cache fallback on network failure, and verifies the no-cache hard error message. Tests: 53 Pester (was 45). All targeted Pull-SDLC tests still green. PSScriptAnalyzer baseline unchanged (0 on Initialize-GitDefaults.ps1, 94 pre-existing on Pull-SDLC.ai.ps1). Co-authored-by: GitHub Copilot --- .github/templates/git-defaults/SOURCES.md | 24 ++- Initialize-GitDefaults.Tests.ps1 | 148 +++++++++++++++-- Initialize-GitDefaults.ps1 | 187 +++++++++++++++++----- 3 files changed, 304 insertions(+), 55 deletions(-) diff --git a/.github/templates/git-defaults/SOURCES.md b/.github/templates/git-defaults/SOURCES.md index 05757f3..0478e95 100644 --- a/.github/templates/git-defaults/SOURCES.md +++ b/.github/templates/git-defaults/SOURCES.md @@ -62,4 +62,26 @@ Invoke-WebRequest "https://raw.githubusercontent.com/github/gitignore/$giSha/Glo ``` Then update the pinned SHAs in `Initialize-GitDefaults.ps1` (the -`$GitattributesRef` and `$GitignoreRef` defaults) and this file. \ No newline at end of file +`$GitattributesRef` and `$GitignoreRef` defaults) and this file. +## Why not `gibo`? + +[`gibo`](https://github.com/simonwhitaker/gibo) is a popular helper that +`curl`s files from `github/gitignore` on demand. `Initialize-GitDefaults.ps1` +intentionally **does not** shell out to gibo: + +1. **Same upstream, fewer dependencies.** gibo serves the exact same files + `Initialize-GitDefaults.ps1` consumes (raw URLs under `github/gitignore`). + Calling gibo would add a runtime dependency that consumers might not have + installed, in exchange for zero new behavior. +2. **SHA pinning.** gibo always hits `master`. `Initialize-GitDefaults.ps1` + pins specific SHAs (above), so a future upstream regression cannot silently + alter generated files until the SHA pin is bumped here and the bundled + snapshots refreshed. +3. **Cache + offline mode.** `-Refresh` writes fetched copies into + `$env:LOCALAPPDATA/IntelliSDLC.ai/git-defaults-cache/` and falls back to + that cache when the network is unavailable; bundled snapshots cover the + no-fetch path. gibo offers no equivalent fallback. + +If you already have a workflow built around `gibo`, you can run it yourself +and supply the resulting files to your repo; this script will not overwrite +them unless you pass `-Force`. \ No newline at end of file diff --git a/Initialize-GitDefaults.Tests.ps1 b/Initialize-GitDefaults.Tests.ps1 index 1b7b57a..e6fe164 100644 --- a/Initialize-GitDefaults.Tests.ps1 +++ b/Initialize-GitDefaults.Tests.ps1 @@ -198,17 +198,6 @@ Describe 'Initialize-GitDefaults (integration)' { $gi | Should -Match 'PSReadLine/ConsoleHost_history\.txt' } - It '-Refresh hard-fails with a message naming the unimplemented fetch path (Copilot review #161)' { - { Initialize-GitDefaults -Language 'CSharp' -Refresh -Force -ErrorAction Stop } | - Should -Throw -ExpectedMessage '*Refresh*not yet implemented*' - Test-Path '.gitattributes' | Should -BeFalse - } - - It 'overriding pinned refs without -Refresh hard-fails to prevent header drift (Copilot review #161)' { - { Initialize-GitDefaults -Language 'CSharp' -GitattributesRef 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' -Force -ErrorAction Stop } | - Should -Throw -ExpectedMessage '*Refresh*' - } - It 'tick-suffixed backups never collide on rapid successive runs (Copilot review #161)' { Set-Content -Path '.gitattributes' -Value 'V1' -NoNewline Initialize-GitDefaults -Language 'CSharp' -Force # .gitattributes.bak @@ -372,3 +361,140 @@ Describe 'Copilot review #161 round 3: kind-aware curated PowerShell header' { $content | Should -Not -Match 'intentional override' } } + +Describe 'Copilot review #161 round 4: -Refresh implementation' { + Context 'fetches from upstream and writes through to disk' { + BeforeEach { + $script:r4Repo = Join-Path ([System.IO.Path]::GetTempPath()) ("r4-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $script:r4Repo | Out-Null + & git -C $script:r4Repo init --quiet 2>&1 | Out-Null + Push-Location $script:r4Repo + $script:fetchedUrls = [System.Collections.Generic.List[string]]::new() + Mock -CommandName 'Invoke-WebRequest' -MockWith { + $script:fetchedUrls.Add($Uri) + # Return distinctive sentinel content per file so we can + # detect that it landed in the generated output. + $name = Split-Path $Uri -Leaf + return [PSCustomObject]@{ Content = "# SENTINEL FOR $name`n" } + } + } + AfterEach { + Pop-Location + Remove-Item -Recurse -Force $script:r4Repo -ErrorAction SilentlyContinue + } + + It '-Refresh fetches every required template from raw.githubusercontent.com' { + Initialize-GitDefaults -Language 'CSharp' -Refresh -Force + Assert-MockCalled -CommandName 'Invoke-WebRequest' -Scope It -Times 1 -ParameterFilter { $Uri -like '*alexkaratarakis/gitattributes*Common.gitattributes' } + Assert-MockCalled -CommandName 'Invoke-WebRequest' -Scope It -Times 1 -ParameterFilter { $Uri -like '*alexkaratarakis/gitattributes*CSharp.gitattributes' } + Assert-MockCalled -CommandName 'Invoke-WebRequest' -Scope It -Times 1 -ParameterFilter { $Uri -like '*github/gitignore*VisualStudio.gitignore' } + Assert-MockCalled -CommandName 'Invoke-WebRequest' -Scope It -Times 1 -ParameterFilter { $Uri -like '*github/gitignore*Global/Backup.gitignore' } + } + + It 'composes generated files from the fetched sentinel content (-Refresh writes through)' { + Initialize-GitDefaults -Language 'CSharp' -Refresh -Force + (Get-Content '.gitattributes' -Raw) | Should -Match 'SENTINEL FOR Common.gitattributes' + (Get-Content '.gitattributes' -Raw) | Should -Match 'SENTINEL FOR CSharp.gitattributes' + (Get-Content '.gitignore' -Raw) | Should -Match 'SENTINEL FOR VisualStudio.gitignore' + (Get-Content '.gitignore' -Raw) | Should -Match 'SENTINEL FOR Backup.gitignore' + } + + It 'header records "fetched from upstream" when -Refresh is used' { + Initialize-GitDefaults -Language 'CSharp' -Refresh -Force + (Get-Content '.gitattributes' -Raw) | Should -Match 'Source mode: fetched from upstream' + (Get-Content '.gitignore' -Raw) | Should -Match 'Source mode: fetched from upstream' + } + + It 'header records "bundled snapshot" when -Refresh is omitted' { + Initialize-GitDefaults -Language 'CSharp' -Force + (Get-Content '.gitattributes' -Raw) | Should -Match 'Source mode: bundled snapshot' + Assert-MockCalled -CommandName 'Invoke-WebRequest' -Scope It -Times 0 + } + + It 'overriding -GitattributesRef changes the URL the fetcher hits' { + Initialize-GitDefaults -Language 'CSharp' -GitattributesRef 'cafebabe1234567890abcdef0987654321fedcba' -Refresh -Force + Assert-MockCalled -CommandName 'Invoke-WebRequest' -Scope It -Times 1 -ParameterFilter { + $Uri -like '*alexkaratarakis/gitattributes/cafebabe1234567890abcdef0987654321fedcba/*' + } + } + } + + Context 'falls back to the on-disk cache when the network fetch fails' { + BeforeEach { + $script:r4Repo = Join-Path ([System.IO.Path]::GetTempPath()) ("r4f-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $script:r4Repo | Out-Null + & git -C $script:r4Repo init --quiet 2>&1 | Out-Null + Push-Location $script:r4Repo + + # Seed the cache for one specific file at a synthetic ref so we + # can prove "fetch fails -> cache hit -> compose continues". + $fakeRef = 'unit-test-ref-' + [System.Guid]::NewGuid().ToString('N').Substring(0,8) + $script:fakeRef = $fakeRef + $cacheDir = Join-Path (Get-GitDefaultsCacheRoot) "alexkaratarakis/gitattributes/$fakeRef" + New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null + Set-Content -Path (Join-Path $cacheDir 'Common.gitattributes') -Value "# CACHED COMMON`n" -NoNewline + Set-Content -Path (Join-Path $cacheDir 'CSharp.gitattributes') -Value "# CACHED CSHARP`n" -NoNewline + $giCacheDir = Join-Path (Get-GitDefaultsCacheRoot) "github/gitignore/$fakeRef" + New-Item -ItemType Directory -Force -Path $giCacheDir | Out-Null + New-Item -ItemType Directory -Force -Path (Join-Path $giCacheDir 'Global') | Out-Null + Set-Content -Path (Join-Path $giCacheDir 'VisualStudio.gitignore') -Value "# CACHED VS`n" -NoNewline + Set-Content -Path (Join-Path $giCacheDir 'Global/Backup.gitignore') -Value "# CACHED BACKUP`n" -NoNewline + + Mock -CommandName 'Invoke-WebRequest' -MockWith { throw 'simulated network failure' } + } + AfterEach { + Pop-Location + Remove-Item -Recurse -Force $script:r4Repo -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force (Join-Path (Get-GitDefaultsCacheRoot) "alexkaratarakis/gitattributes/$script:fakeRef") -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force (Join-Path (Get-GitDefaultsCacheRoot) "github/gitignore/$script:fakeRef") -ErrorAction SilentlyContinue + } + + It 'uses cached content when network fetch fails AND cache hit exists' { + Initialize-GitDefaults -Language 'CSharp' -Refresh -Force -GitattributesRef $script:fakeRef -GitignoreRef $script:fakeRef -WarningAction SilentlyContinue + (Get-Content '.gitattributes' -Raw) | Should -Match 'CACHED COMMON' + (Get-Content '.gitattributes' -Raw) | Should -Match 'CACHED CSHARP' + (Get-Content '.gitignore' -Raw) | Should -Match 'CACHED BACKUP' + } + + It 'throws clearly when network fails AND no cache exists' { + $unseededRef = 'never-cached-' + [System.Guid]::NewGuid().ToString('N').Substring(0,8) + { Initialize-GitDefaults -Language 'CSharp' -Refresh -Force -GitattributesRef $unseededRef -GitignoreRef $unseededRef -ErrorAction Stop } | + Should -Throw -ExpectedMessage '*no cached copy*' + } + } +} + +Describe 'Copilot review #161 round 4: include-switch one-file invocations' { + BeforeEach { + $script:swRepo = Join-Path ([System.IO.Path]::GetTempPath()) ("sw-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $script:swRepo | Out-Null + & git -C $script:swRepo init --quiet 2>&1 | Out-Null + Push-Location $script:swRepo + } + AfterEach { + Pop-Location + Remove-Item -Recurse -Force $script:swRepo -ErrorAction SilentlyContinue + } + + It '-IncludeGitignore:$false writes .gitattributes only and leaves an existing .gitignore untouched' { + Set-Content -Path '.gitignore' -Value 'consumer-owned' -NoNewline + Initialize-GitDefaults -Language 'CSharp' -IncludeGitignore:$false -Force + Test-Path '.gitattributes' | Should -BeTrue + (Get-Content '.gitignore' -Raw) | Should -Be 'consumer-owned' + # No .bak should have been created for .gitignore + Test-Path '.gitignore.bak' | Should -BeFalse + } + + It '-IncludeGitattributes:$false writes .gitignore only and leaves an existing .gitattributes untouched' { + Set-Content -Path '.gitattributes' -Value '* text=auto' -NoNewline + Initialize-GitDefaults -Language 'CSharp' -IncludeGitattributes:$false -Force + Test-Path '.gitignore' | Should -BeTrue + (Get-Content '.gitattributes' -Raw) | Should -Be '* text=auto' + Test-Path '.gitattributes.bak' | Should -BeFalse + } + + It '-IncludeGitignore:$false with no existing .gitignore does not create one' { + Initialize-GitDefaults -Language 'CSharp' -IncludeGitignore:$false -Force + Test-Path '.gitignore' | Should -BeFalse + } +} diff --git a/Initialize-GitDefaults.ps1 b/Initialize-GitDefaults.ps1 index 6fc7cbb..faecaf9 100644 --- a/Initialize-GitDefaults.ps1 +++ b/Initialize-GitDefaults.ps1 @@ -17,8 +17,9 @@ commits/main` returns 404). Snapshots are bundled under `.github/templates/git-defaults/` at the pinned - SHAs and used by default. The `-Refresh` switch is reserved for a future - network-fetch path and currently hard-errors (see .PARAMETER Refresh). + SHAs and used by default. Pass `-Refresh` to fetch fresh copies from + GitHub raw at the requested refs into a local cache and use those + instead (with cache fallback if the network is unavailable). .PARAMETER Language Languages to include. Validated against the supported set. ASP.NET implies @@ -34,20 +35,24 @@ Compose `.gitattributes`. Default: $true. .PARAMETER GitattributesRef - Git SHA in alexkaratarakis/gitattributes the bundled snapshots are pinned to. - Overriding this only makes sense alongside -Refresh (network fetch), which - is not yet implemented; passing a non-default value without -Refresh hard- - errors to prevent header drift. + Git SHA in alexkaratarakis/gitattributes used when fetching with + `-Refresh`. The bundled snapshot is pinned to this same SHA by default; + overriding it without `-Refresh` is allowed (the header still names this + ref) but the on-disk bytes come from the bundled snapshot. .PARAMETER GitignoreRef - Git SHA in github/gitignore the bundled snapshots are pinned to. Same - override semantics as -GitattributesRef. + Git SHA in github/gitignore used when fetching with `-Refresh`. Same + bundled-vs-fetched semantics as `-GitattributesRef`. .PARAMETER Refresh - RESERVED. Intended to bypass bundled snapshots and fetch fresh copies from - GitHub over HTTPS at the requested refs. NOT YET IMPLEMENTED in this - release; passing -Refresh hard-errors. Omit -Refresh to compose from the - bundled snapshots at the pinned SHAs. + Fetch fresh copies of each upstream template file from + `raw.githubusercontent.com///` into the local cache + (`$env:LOCALAPPDATA/IntelliSDLC.ai/git-defaults-cache/`) and compose + from those instead of the bundled snapshots. Falls back to the cached + copy if a fetch fails. Generated-file headers say "fetched from + upstream" when this is set, "bundled snapshot" otherwise. Note: the + curated PowerShell block is always emitted in-script and is unaffected + by `-Refresh`. .PARAMETER Force Overwrite existing `.gitattributes` / `.gitignore` after backing the @@ -66,6 +71,12 @@ ./Initialize-GitDefaults.ps1 # Interactive picker: prompts with heuristically-detected languages as # the default. Non-interactive hosts must pass -Language explicitly. + +.EXAMPLE + ./Initialize-GitDefaults.ps1 -Language CSharp,PowerShell -Refresh -Force + # Fetch upstream templates fresh from raw.githubusercontent.com at the + # pinned SHAs into the local cache, then compose .gitattributes and + # .gitignore from them. #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'User-facing CLI output matching the project-wide convention.')] @@ -81,6 +92,9 @@ param( [switch] $Force ) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + # ----- Constants ----------------------------------------------------------- # Canonical language registry. Order of `Sections` controls divider naming. @@ -117,6 +131,11 @@ PSReadLine/ConsoleHost_history.txt $script:GitattributesAuthority = 'community de facto; no GitHub-org source exists' $script:GitignoreAuthority = 'GitHub-org authoritative' +# Pinned SHA defaults. Mirror the top-level param defaults so internal +# composers can be called without explicit refs (tests, library use). +$script:DefaultGitattributesRef = 'fddc586cf0f10ec4485028d0d2dd6f73197a4258' +$script:DefaultGitignoreRef = 'dcc0fc7bc2b5ba480cf117ad1be31bafceeaff46' + # ----- Helpers ------------------------------------------------------------- function Get-GitDefaultsTemplateRoot { @@ -166,19 +185,105 @@ function Resolve-GitDefaultsLanguages { return @($resolved | Sort-Object) } +function Get-GitDefaultsCacheRoot { + <# + .SYNOPSIS + Local on-disk cache for fetched upstream snapshots. + #> + [CmdletBinding()] + [OutputType([string])] + param() + $base = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } + elseif ($env:XDG_CACHE_HOME) { $env:XDG_CACHE_HOME } + elseif ($env:HOME) { Join-Path $env:HOME '.cache' } + else { [System.IO.Path]::GetTempPath() } + return (Join-Path $base 'IntelliSDLC.ai/git-defaults-cache') +} + +function Resolve-GitDefaultsSourceRepo { + <# + .SYNOPSIS + Map a template file name to (Repo, Ref) using the supplied defaults. + '.gitattributes' files come from alexkaratarakis/gitattributes; + '.gitignore' files come from github/gitignore. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] [string] $FileName, + [Parameter(Mandatory)] [string] $GitattributesRef, + [Parameter(Mandatory)] [string] $GitignoreRef + ) + if ($FileName -like '*.gitattributes') { + return @{ Repo = 'alexkaratarakis/gitattributes'; Ref = $GitattributesRef } + } + if ($FileName -like '*.gitignore') { + return @{ Repo = 'github/gitignore'; Ref = $GitignoreRef } + } + throw "Cannot infer source repo for template file '$FileName' (expected *.gitattributes or *.gitignore)." +} + +function Get-GitDefaultsRefreshedContent { + <# + .SYNOPSIS + Fetch a template file from raw.githubusercontent.com at the requested + ref, write it to the local cache, and return its content. Falls back + to a previously-cached copy if the network fetch fails. + #> + [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidOverwritingBuiltInCmdlets', '', Justification = 'Helper internal to this script.')] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $FileName, + [Parameter(Mandatory)] [string] $Repo, + [Parameter(Mandatory)] [string] $Ref + ) + $cacheDir = Join-Path (Get-GitDefaultsCacheRoot) "$Repo/$Ref" + $cachePath = Join-Path $cacheDir $FileName + $cacheParent = Split-Path -Parent $cachePath + if (-not (Test-Path -LiteralPath $cacheParent)) { + New-Item -ItemType Directory -Force -Path $cacheParent | Out-Null + } + $url = "https://raw.githubusercontent.com/$Repo/$Ref/$FileName" + try { + $resp = Invoke-WebRequest -Uri $url -UseBasicParsing -ErrorAction Stop + $content = if ($resp.Content -is [byte[]]) { + [System.Text.Encoding]::UTF8.GetString($resp.Content) + } else { + [string]$resp.Content + } + [System.IO.File]::WriteAllText($cachePath, $content, [System.Text.UTF8Encoding]::new($false)) + return $content + } catch { + if (Test-Path -LiteralPath $cachePath) { + Write-Warning "Refresh fetch failed ($($_.Exception.Message)); using cached copy at $cachePath." + return (Get-Content -LiteralPath $cachePath -Raw) + } + throw "Refresh of '$FileName' from $url failed and no cached copy exists at $cachePath. Original error: $($_.Exception.Message)" + } +} + function Get-GitDefaultsTemplateContent { <# .SYNOPSIS - Read a bundled template snapshot from .github/templates/git-defaults/. + Return the body of a template file -- either the bundled snapshot + (default) or a fresh copy fetched from upstream when -Refresh is set. #> [CmdletBinding()] [OutputType([string])] param( - [Parameter(Mandatory)] [string] $FileName + [Parameter(Mandatory)] [string] $FileName, + [string] $GitattributesRef, + [string] $GitignoreRef, + [switch] $Refresh ) + if ($Refresh) { + $src = Resolve-GitDefaultsSourceRepo -FileName $FileName -GitattributesRef $GitattributesRef -GitignoreRef $GitignoreRef + return (Get-GitDefaultsRefreshedContent -FileName $FileName -Repo $src.Repo -Ref $src.Ref) + } $path = Join-Path (Get-GitDefaultsTemplateRoot) $FileName if (-not (Test-Path -LiteralPath $path)) { - throw "Bundled template snapshot not found: $path. Re-pull IntelliSDLC.ai (Pull-SDLC.ai.ps1) to restore the .github/templates/git-defaults/ snapshots." + throw "Bundled template snapshot not found: $path. Re-pull IntelliSDLC.ai (Pull-SDLC.ai.ps1) to restore the .github/templates/git-defaults/ snapshots, or pass -Refresh to fetch from upstream." } return (Get-Content -LiteralPath $path -Raw) } @@ -194,15 +299,18 @@ function New-GitDefaultsHeader { [Parameter(Mandatory)] [ValidateSet('gitattributes','gitignore')] [string] $Kind, [Parameter(Mandatory)] [string[]] $Language, [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]] $UpstreamSections, - [string] $GibootstrapNote + [string] $GibootstrapNote, + [ValidateSet('bundled','fetched')] [string] $SourceMode = 'bundled' ) $iso = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') $langList = ($Language -join ', ') $hasPs = $Language -contains 'PowerShell' + $modeLabel = if ($SourceMode -eq 'fetched') { 'fetched from upstream' } else { 'bundled snapshot' } $lines = [System.Collections.Generic.List[string]]::new() [void]$lines.Add("# Generated by Initialize-GitDefaults.ps1 on $iso") [void]$lines.Add("# Languages: $langList") + [void]$lines.Add("# Source mode: $modeLabel") [void]$lines.Add('# Sources:') if ($Kind -eq 'gitattributes') { $sectionList = if ($UpstreamSections.Count -gt 0) { ($UpstreamSections -join ', ') } else { '(none)' } @@ -253,9 +361,13 @@ function New-GitAttributesContent { [CmdletBinding()] [OutputType([string])] param( - [Parameter(Mandatory)] [string[]] $Language + [Parameter(Mandatory)] [string[]] $Language, + [string] $GitattributesRef = $script:DefaultGitattributesRef, + [string] $GitignoreRef = $script:DefaultGitignoreRef, + [switch] $Refresh ) $expanded = Resolve-GitDefaultsLanguages -Language $Language + $fetchSplat = @{ GitattributesRef = $GitattributesRef; GitignoreRef = $GitignoreRef; Refresh = [bool]$Refresh } $upstreamSections = [System.Collections.Generic.List[string]]::new() [void]$upstreamSections.Add('Common') @@ -268,12 +380,12 @@ function New-GitAttributesContent { } } - $header = New-GitDefaultsHeader -Kind 'gitattributes' -Language $expanded -UpstreamSections $upstreamSections + $header = New-GitDefaultsHeader -Kind 'gitattributes' -Language $expanded -UpstreamSections $upstreamSections -SourceMode $(if ($Refresh) { 'fetched' } else { 'bundled' }) $body = [System.Collections.Generic.List[string]]::new() [void]$body.Add($header) [void]$body.Add((New-GitDefaultsSectionDivider -Section 'Common' -SourceLabel 'Common.gitattributes')) - [void]$body.Add((Get-GitDefaultsTemplateContent -FileName 'Common.gitattributes')) + [void]$body.Add((Get-GitDefaultsTemplateContent -FileName 'Common.gitattributes' @fetchSplat)) $emitted = [System.Collections.Generic.HashSet[string]]::new() foreach ($lang in $expanded) { @@ -281,7 +393,7 @@ function New-GitAttributesContent { if ($entry.GitattrFile -and $emitted.Add($entry.GitattrFile)) { $section = [System.IO.Path]::GetFileNameWithoutExtension($entry.GitattrFile) [void]$body.Add((New-GitDefaultsSectionDivider -Section $section -SourceLabel $entry.GitattrFile)) - [void]$body.Add((Get-GitDefaultsTemplateContent -FileName $entry.GitattrFile)) + [void]$body.Add((Get-GitDefaultsTemplateContent -FileName $entry.GitattrFile @fetchSplat)) } elseif ($lang -eq 'PowerShell') { [void]$body.Add((New-GitDefaultsSectionDivider -Section 'PowerShell' -SourceLabel 'curated in-script')) [void]$body.Add($script:CuratedPowerShellGitattributes) @@ -298,9 +410,13 @@ function New-GitIgnoreContent { [CmdletBinding()] [OutputType([string])] param( - [Parameter(Mandatory)] [string[]] $Language + [Parameter(Mandatory)] [string[]] $Language, + [string] $GitattributesRef = $script:DefaultGitattributesRef, + [string] $GitignoreRef = $script:DefaultGitignoreRef, + [switch] $Refresh ) $expanded = Resolve-GitDefaultsLanguages -Language $Language + $fetchSplat = @{ GitattributesRef = $GitattributesRef; GitignoreRef = $GitignoreRef; Refresh = [bool]$Refresh } $upstreamSections = [System.Collections.Generic.List[string]]::new() $seenFiles = [System.Collections.Generic.HashSet[string]]::new() @@ -319,7 +435,7 @@ function New-GitIgnoreContent { [void]$upstreamSections.Add('Global/Backup') } - $header = New-GitDefaultsHeader -Kind 'gitignore' -Language $expanded -UpstreamSections $upstreamSections + $header = New-GitDefaultsHeader -Kind 'gitignore' -Language $expanded -UpstreamSections $upstreamSections -SourceMode $(if ($Refresh) { 'fetched' } else { 'bundled' }) $body = [System.Collections.Generic.List[string]]::new() [void]$body.Add($header) @@ -330,7 +446,7 @@ function New-GitIgnoreContent { if ($entry.GitignoreFile -and $emitted.Add($entry.GitignoreFile)) { $section = [System.IO.Path]::GetFileNameWithoutExtension($entry.GitignoreFile) [void]$body.Add((New-GitDefaultsSectionDivider -Section $section -SourceLabel $entry.GitignoreFile)) - [void]$body.Add((Get-GitDefaultsTemplateContent -FileName $entry.GitignoreFile)) + [void]$body.Add((Get-GitDefaultsTemplateContent -FileName $entry.GitignoreFile @fetchSplat)) } elseif ($lang -eq 'PowerShell') { [void]$body.Add((New-GitDefaultsSectionDivider -Section 'PowerShell' -SourceLabel 'curated in-script')) [void]$body.Add($script:CuratedPowerShellGitignore) @@ -339,7 +455,7 @@ function New-GitIgnoreContent { # Cross-platform editor/OS backup patterns, appended once. if ($emitted.Add($backupFile)) { [void]$body.Add((New-GitDefaultsSectionDivider -Section 'Global/Backup' -SourceLabel $backupFile)) - [void]$body.Add((Get-GitDefaultsTemplateContent -FileName $backupFile)) + [void]$body.Add((Get-GitDefaultsTemplateContent -FileName $backupFile @fetchSplat)) } return ($body -join "`n") } @@ -526,23 +642,6 @@ function Initialize-GitDefaults { throw "Current directory is not a git repository. Run 'git init' first or cd into a repo." } - if ($Refresh) { - # Honest stance: -Refresh is reserved for the network-fetch path, - # which is not implemented in this release. Hard-fail rather than - # silently emitting bundled content under a "refreshed" header - # (which would mislead consumers about the actual source). - throw '-Refresh (network fetch from upstream at -GitattributesRef/-GitignoreRef) is not yet implemented. Omit -Refresh to compose from the bundled snapshots, or open an issue to prioritise the fetch path.' - } - - # Guard against header drift when consumers override the pinned refs: - # the bundled snapshots are pinned to specific SHAs, so we cannot - # honestly claim a different ref in the header without fetching. - $defaultGitattrRef = 'fddc586cf0f10ec4485028d0d2dd6f73197a4258' - $defaultGitignoreRef = 'dcc0fc7bc2b5ba480cf117ad1be31bafceeaff46' - if ($GitattributesRef -ne $defaultGitattrRef -or $GitignoreRef -ne $defaultGitignoreRef) { - throw "Overriding -GitattributesRef or -GitignoreRef requires -Refresh to actually fetch that ref, and -Refresh is not yet implemented. Use the pinned defaults ($defaultGitattrRef / $defaultGitignoreRef)." - } - if (-not $Language -or $Language.Count -eq 0) { $Language = Read-GitDefaultsLanguageSelection if (-not $Language -or $Language.Count -eq 0) { @@ -565,13 +664,15 @@ function Initialize-GitDefaults { } } + $composeSplat = @{ GitattributesRef = $GitattributesRef; GitignoreRef = $GitignoreRef; Refresh = [bool]$Refresh } + if ($IncludeGitattributes) { - $content = New-GitAttributesContent -Language $expanded + $content = New-GitAttributesContent -Language $expanded @composeSplat Write-GitDefaultsFile -Path '.gitattributes' -Content $content -Force:$Force if (-not $WhatIfPreference) { Write-Host "Wrote .gitattributes ($($expanded -join ', '))" -ForegroundColor Green } } if ($IncludeGitignore) { - $content = New-GitIgnoreContent -Language $expanded + $content = New-GitIgnoreContent -Language $expanded @composeSplat Write-GitDefaultsFile -Path '.gitignore' -Content $content -Force:$Force if (-not $WhatIfPreference) { Write-Host "Wrote .gitignore ($($expanded -join ', '))" -ForegroundColor Green } } From 74d42fd9240f77d9cbbb1cb4d78e24973161ec1a Mon Sep 17 00:00:00 2001 From: Mark Michaelis Date: Sat, 30 May 2026 20:56:18 -0700 Subject: [PATCH 07/10] fix(git-defaults): address Copilot review round 5 on #161 Five threads from the round-5 Copilot review on PR #161: 1. d9R atomic compose-then-write: Initialize-GitDefaults composes both .gitattributes and .gitignore in memory BEFORE writing either. A failure during the second compose (network/cache miss under -Refresh) no longer leaves the consumer half-updated. 2. d9T -WhatIf cache safety: Get-GitDefaultsRefreshedContent now uses SupportsShouldProcess and gates the cache write through ShouldProcess so -WhatIf does not mutate the on-disk cache. Fetched content is still returned so the dry-run preview composes correctly. 3. d9X SOURCES.md stale procedure: rewrote the Refresh procedure section to distinguish (A) the now-implemented runtime cache refresh consumers get via -Refresh from (B) the maintainer-only bundled-snapshot bump procedure in this repo. 4. d9Y + d9d tombstone: retained .gitattributes.template in $script:UpstreamManagedPaths as a tombstone (with explanatory comment) so already-onboarded consumers receive the deletion op from Get-UpstreamOps. Inverted the corresponding Pester assertion. Tests: 55 Initialize-GitDefaults tests + 21 targeted Pull-SDLC tests all GREEN. PSSA unchanged from prior commit (3 baseline on Initialize-GitDefaults.ps1, 146 baseline on Pull-SDLC.ai.ps1; the 3 on the new script are pre-existing PSUseOutputTypeCorrectly findings unrelated to this change). Refs #160 Co-authored-by: GitHub Copilot --- .github/templates/git-defaults/SOURCES.md | 28 ++++++++-- Initialize-GitDefaults.Tests.ps1 | 66 +++++++++++++++++++++++ Initialize-GitDefaults.ps1 | 35 ++++++++---- Pull-SDLC.ai.Tests.ps1 | 32 +++++------ Pull-SDLC.ai.ps1 | 9 +++- 5 files changed, 139 insertions(+), 31 deletions(-) diff --git a/.github/templates/git-defaults/SOURCES.md b/.github/templates/git-defaults/SOURCES.md index 0478e95..6fcc1bb 100644 --- a/.github/templates/git-defaults/SOURCES.md +++ b/.github/templates/git-defaults/SOURCES.md @@ -43,8 +43,27 @@ From `github/gitignore`: ## Refresh procedure -Run the bootstrap script with `-Refresh` (network fetch path; future -work). Manual procedure today: +There are two distinct refresh operations -- don't conflate them. + +### A. Runtime refresh (consumers, on every `Initialize-GitDefaults.ps1` run) + +`./Initialize-GitDefaults.ps1 -Language ... -Refresh -Force` fetches each +required upstream file from `raw.githubusercontent.com///` +into the local cache at +`$env:LOCALAPPDATA/IntelliSDLC.ai/git-defaults-cache///` and +composes the consumer's `.gitattributes` / `.gitignore` from those instead +of the bundled snapshots committed alongside this file. Generated-file +headers include `Source mode: fetched from upstream` when `-Refresh` was +used. Cache-miss + network failure throws clearly; cache-hit + network +failure logs a warning and continues. `-Refresh -WhatIf` previews without +mutating the cache. This path does **not** update the bundled snapshots +in this directory -- it only affects what gets written into the +consumer's repo for that run. + +### B. Bundled-snapshot refresh (maintainers of this repo only) + +To bump the pinned SHAs and refresh the committed snapshots so the +default (non-`-Refresh`) path picks up upstream changes: ```powershell $gaSha = '' @@ -62,7 +81,10 @@ Invoke-WebRequest "https://raw.githubusercontent.com/github/gitignore/$giSha/Glo ``` Then update the pinned SHAs in `Initialize-GitDefaults.ps1` (the -`$GitattributesRef` and `$GitignoreRef` defaults) and this file. +`$GitattributesRef` / `$GitignoreRef` param defaults and the +`$script:DefaultGitattributesRef` / `$script:DefaultGitignoreRef` mirrors) +and this file. + ## Why not `gibo`? [`gibo`](https://github.com/simonwhitaker/gibo) is a popular helper that diff --git a/Initialize-GitDefaults.Tests.ps1 b/Initialize-GitDefaults.Tests.ps1 index e6fe164..4581278 100644 --- a/Initialize-GitDefaults.Tests.ps1 +++ b/Initialize-GitDefaults.Tests.ps1 @@ -498,3 +498,69 @@ Describe 'Copilot review #161 round 4: include-switch one-file invocations' { Test-Path '.gitignore' | Should -BeFalse } } + +Describe 'Copilot review #161 round 5: atomicity + -WhatIf cache safety' { + Context 'compose-then-write is atomic across both targets' { + BeforeEach { + $script:atRepo = Join-Path ([System.IO.Path]::GetTempPath()) ("at-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $script:atRepo | Out-Null + & git -C $script:atRepo init --quiet 2>&1 | Out-Null + Push-Location $script:atRepo + # Mock Invoke-WebRequest to succeed for gitattributes URLs and + # fail for gitignore URLs, so the second compose throws AFTER + # the first compose succeeded. The atomic guard must prevent + # .gitattributes from being written. + Mock -CommandName 'Invoke-WebRequest' -MockWith { + if ($Uri -like '*github/gitignore*') { + throw 'simulated gitignore-only fetch failure' + } + $name = Split-Path $Uri -Leaf + return [PSCustomObject]@{ Content = "# OK $name`n" } + } + } + AfterEach { + Pop-Location + Remove-Item -Recurse -Force $script:atRepo -ErrorAction SilentlyContinue + } + + It 'fails the whole operation without writing either file when the second compose throws' { + $uniq = 'atom-' + [System.Guid]::NewGuid().ToString('N').Substring(0,8) + { Initialize-GitDefaults -Language 'CSharp' -Refresh -Force -GitignoreRef $uniq -ErrorAction Stop } | + Should -Throw -ExpectedMessage '*gitignore*' + Test-Path '.gitattributes' | Should -BeFalse + Test-Path '.gitignore' | Should -BeFalse + } + } + + Context '-WhatIf does not mutate the on-disk cache' { + BeforeEach { + $script:atRepo = Join-Path ([System.IO.Path]::GetTempPath()) ("wi-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $script:atRepo | Out-Null + & git -C $script:atRepo init --quiet 2>&1 | Out-Null + Push-Location $script:atRepo + $script:whatIfRef = 'whatif-' + [System.Guid]::NewGuid().ToString('N').Substring(0,8) + Mock -CommandName 'Invoke-WebRequest' -MockWith { + $name = Split-Path $Uri -Leaf + return [PSCustomObject]@{ Content = "# WHATIF $name`n" } + } + } + AfterEach { + Pop-Location + Remove-Item -Recurse -Force $script:atRepo -ErrorAction SilentlyContinue + $a = Join-Path (Get-GitDefaultsCacheRoot) "alexkaratarakis/gitattributes/$script:whatIfRef" + $b = Join-Path (Get-GitDefaultsCacheRoot) "github/gitignore/$script:whatIfRef" + Remove-Item -Recurse -Force $a -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $b -ErrorAction SilentlyContinue + } + + It 'leaves no cached file behind under -WhatIf with -Refresh' { + Initialize-GitDefaults -Language 'CSharp' -Refresh -Force -GitattributesRef $script:whatIfRef -GitignoreRef $script:whatIfRef -WhatIf + $a = Join-Path (Get-GitDefaultsCacheRoot) "alexkaratarakis/gitattributes/$script:whatIfRef" + $b = Join-Path (Get-GitDefaultsCacheRoot) "github/gitignore/$script:whatIfRef" + Test-Path $a | Should -BeFalse + Test-Path $b | Should -BeFalse + Test-Path '.gitattributes' | Should -BeFalse + Test-Path '.gitignore' | Should -BeFalse + } + } +} diff --git a/Initialize-GitDefaults.ps1 b/Initialize-GitDefaults.ps1 index faecaf9..4ccf165 100644 --- a/Initialize-GitDefaults.ps1 +++ b/Initialize-GitDefaults.ps1 @@ -230,7 +230,7 @@ function Get-GitDefaultsRefreshedContent { ref, write it to the local cache, and return its content. Falls back to a previously-cached copy if the network fetch fails. #> - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidOverwritingBuiltInCmdlets', '', Justification = 'Helper internal to this script.')] [OutputType([string])] param( @@ -241,9 +241,6 @@ function Get-GitDefaultsRefreshedContent { $cacheDir = Join-Path (Get-GitDefaultsCacheRoot) "$Repo/$Ref" $cachePath = Join-Path $cacheDir $FileName $cacheParent = Split-Path -Parent $cachePath - if (-not (Test-Path -LiteralPath $cacheParent)) { - New-Item -ItemType Directory -Force -Path $cacheParent | Out-Null - } $url = "https://raw.githubusercontent.com/$Repo/$Ref/$FileName" try { $resp = Invoke-WebRequest -Uri $url -UseBasicParsing -ErrorAction Stop @@ -252,7 +249,15 @@ function Get-GitDefaultsRefreshedContent { } else { [string]$resp.Content } - [System.IO.File]::WriteAllText($cachePath, $content, [System.Text.UTF8Encoding]::new($false)) + # Respect -WhatIf: do not mutate the on-disk cache during a dry + # run. The fetched content is still returned so the caller can + # compose the "would-write" preview. Copilot review #161 round 5. + if ($PSCmdlet.ShouldProcess($cachePath, "Cache fetched template")) { + if (-not (Test-Path -LiteralPath $cacheParent)) { + New-Item -ItemType Directory -Force -Path $cacheParent | Out-Null + } + [System.IO.File]::WriteAllText($cachePath, $content, [System.Text.UTF8Encoding]::new($false)) + } return $content } catch { if (Test-Path -LiteralPath $cachePath) { @@ -666,15 +671,23 @@ function Initialize-GitDefaults { $composeSplat = @{ GitattributesRef = $GitattributesRef; GitignoreRef = $GitignoreRef; Refresh = [bool]$Refresh } + # Compose ALL requested outputs before writing ANY, so a failure + # during the second compose (network/cache miss under -Refresh, + # template-snapshot-missing, etc.) leaves the repo unchanged rather + # than half-updated. Copilot review #161 round 5. + $pending = [ordered]@{} if ($IncludeGitattributes) { - $content = New-GitAttributesContent -Language $expanded @composeSplat - Write-GitDefaultsFile -Path '.gitattributes' -Content $content -Force:$Force - if (-not $WhatIfPreference) { Write-Host "Wrote .gitattributes ($($expanded -join ', '))" -ForegroundColor Green } + $pending['.gitattributes'] = New-GitAttributesContent -Language $expanded @composeSplat } if ($IncludeGitignore) { - $content = New-GitIgnoreContent -Language $expanded @composeSplat - Write-GitDefaultsFile -Path '.gitignore' -Content $content -Force:$Force - if (-not $WhatIfPreference) { Write-Host "Wrote .gitignore ($($expanded -join ', '))" -ForegroundColor Green } + $pending['.gitignore'] = New-GitIgnoreContent -Language $expanded @composeSplat + } + + foreach ($path in $pending.Keys) { + Write-GitDefaultsFile -Path $path -Content $pending[$path] -Force:$Force + if (-not $WhatIfPreference) { + Write-Host "Wrote $path ($($expanded -join ', '))" -ForegroundColor Green + } } } diff --git a/Pull-SDLC.ai.Tests.ps1 b/Pull-SDLC.ai.Tests.ps1 index 19e0046..dccbf56 100644 --- a/Pull-SDLC.ai.Tests.ps1 +++ b/Pull-SDLC.ai.Tests.ps1 @@ -2182,22 +2182,22 @@ Describe 'Issue #148: bootstrap-on-main carve-out hygiene' { Describe 'Initialize-GitDefaults migration (issue #160)' { - It 'no longer scaffolds .gitattributes from a template' { - $script:TemplateScaffoldMap.Keys | Should -Not -Contain '.gitattributes.template' - $script:TemplateScaffoldMap.Values | Should -Not -Contain '.gitattributes' - } - - It 'no longer manages .gitattributes.template as upstream' { - $script:UpstreamManagedPaths | Should -Not -Contain '.gitattributes.template' - } - - It 'manages .github/templates/git-defaults/ as upstream' { - $script:UpstreamManagedPaths | Should -Contain '.github/templates/git-defaults/' - } - - It '.gitattributes remains consumer-owned (always-local)' { - $script:AlwaysLocalPaths | Should -Contain '.gitattributes' - } + It 'no longer scaffolds .gitattributes from a template' { + $script:TemplateScaffoldMap.Keys | Should -Not -Contain '.gitattributes.template' + $script:TemplateScaffoldMap.Values | Should -Not -Contain '.gitattributes' + } + + It 'retains .gitattributes.template as an UpstreamManagedPaths tombstone so existing consumers receive the deletion op (Copilot review #161 round 5)' { + $script:UpstreamManagedPaths | Should -Contain '.gitattributes.template' + } + + It 'manages .github/templates/git-defaults/ as upstream' { + $script:UpstreamManagedPaths | Should -Contain '.github/templates/git-defaults/' + } + + It '.gitattributes remains consumer-owned (always-local)' { + $script:AlwaysLocalPaths | Should -Contain '.gitattributes' + } } Describe 'Write-GitDefaultsHint (issue #160)' { diff --git a/Pull-SDLC.ai.ps1 b/Pull-SDLC.ai.ps1 index 8012bd1..d29e2fa 100644 --- a/Pull-SDLC.ai.ps1 +++ b/Pull-SDLC.ai.ps1 @@ -178,7 +178,14 @@ $script:UpstreamManagedPaths = @( # post-sync hint points at. Without this entry the hint points at a # script that does not exist in fresh consumer worktrees. 'Initialize-GitDefaults.ps1', - 'Initialize-GitDefaults.Tests.ps1' + 'Initialize-GitDefaults.Tests.ps1', + # Tombstone for the legacy first-sync template removed in issue #160. + # Keeping the path in UpstreamManagedPaths means the diff against + # upstream emits a `D` op for any consumer that had previously synced + # the file -- the upstream tree no longer contains it, so the op + # deletes the consumer's stale copy. Once consumers in the wild are + # past the transition (a few months), this entry can be removed. + '.gitattributes.template' ) # Subset of UpstreamManagedPaths whose mere presence in the consumer's working From b9d9b74d4f14a896706f9bf9e0ba8cd930bb4bce Mon Sep 17 00:00:00 2001 From: Mark Michaelis Date: Sat, 30 May 2026 21:07:35 -0700 Subject: [PATCH 08/10] fix(git-defaults): address Copilot review round 6 on #161 Five new threads from the round-6 Copilot review on PR #161: 1. hJM cache pollution from tests: the round-4 -Refresh tests used the real default pinned SHAs while mocking Invoke-WebRequest, so sentinel content was being written into the user's real cache for production refs. The tests now generate a synthetic per-test ref, pass it explicitly to every Initialize-GitDefaults call, and clean up the cache entries in AfterEach. 2. hJQ dynamic-scope hazard in New-GitDefaultsHeader: -GitattributesRef / -GitignoreRef are now explicit parameters of the helper with defaults from $script:DefaultGit*Ref. Both internal call sites (New-GitAttributesContent, New-GitIgnoreContent) pass them through. The helper no longer relies on the caller's scope. 3. hJY swallowed cache-write failures: split the single try/catch in Get-GitDefaultsRefreshedContent so that (a) fetch failures fall back to a cached copy if available, and (b) a successful fetch whose cache write later fails warns once and still returns the freshly-fetched content. Stale cached copies can no longer silently replace a good download. 4. hJd .slnx detection gap: Get-GitDefaultsDetectedLanguages now includes *.slnx alongside *.csproj and *.sln when detecting CSharp, matching the generated .gitattributes which already handles .slnx. 5. hJg tombstone staging gap: the Pull-SDLC.ai staging loop now includes a tracked-but-deleted managed path (via git ls-files --error-unmatch) so the D op produced for the .gitattributes.template tombstone reaches the sync commit instead of being left unstaged. Tests: 57 Initialize-GitDefaults tests + 22 targeted Pull-SDLC tests (including a new "Tombstone deletion staging" describe that verifies a tracked-but-deleted path gets staged as a deletion) all GREEN. PSSA baseline unchanged: 3 on Initialize-GitDefaults.ps1, 146 on Pull-SDLC.ai.ps1. Refs #160 Co-authored-by: GitHub Copilot --- Initialize-GitDefaults.Tests.ps1 | 71 ++++++++++++++++++++++++++++++-- Initialize-GitDefaults.ps1 | 44 ++++++++++++-------- Pull-SDLC.ai.Tests.ps1 | 41 ++++++++++++++++++ Pull-SDLC.ai.ps1 | 17 ++++++-- 4 files changed, 147 insertions(+), 26 deletions(-) diff --git a/Initialize-GitDefaults.Tests.ps1 b/Initialize-GitDefaults.Tests.ps1 index 4581278..5e08184 100644 --- a/Initialize-GitDefaults.Tests.ps1 +++ b/Initialize-GitDefaults.Tests.ps1 @@ -369,6 +369,10 @@ Describe 'Copilot review #161 round 4: -Refresh implementation' { New-Item -ItemType Directory -Path $script:r4Repo | Out-Null & git -C $script:r4Repo init --quiet 2>&1 | Out-Null Push-Location $script:r4Repo + # Use a synthetic per-test ref so the mocked Invoke-WebRequest + # responses never pollute the real cache for production pinned + # SHAs. Copilot review #161 round 6. + $script:r4Ref = 'r4-' + [System.Guid]::NewGuid().ToString('N').Substring(0,12) $script:fetchedUrls = [System.Collections.Generic.List[string]]::new() Mock -CommandName 'Invoke-WebRequest' -MockWith { $script:fetchedUrls.Add($Uri) @@ -381,10 +385,18 @@ Describe 'Copilot review #161 round 4: -Refresh implementation' { AfterEach { Pop-Location Remove-Item -Recurse -Force $script:r4Repo -ErrorAction SilentlyContinue + if ($script:r4Ref) { + $a = Join-Path (Get-GitDefaultsCacheRoot) "alexkaratarakis/gitattributes/$script:r4Ref" + $b = Join-Path (Get-GitDefaultsCacheRoot) "github/gitignore/$script:r4Ref" + Remove-Item -Recurse -Force $a -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $b -ErrorAction SilentlyContinue + } + $custom = Join-Path (Get-GitDefaultsCacheRoot) 'alexkaratarakis/gitattributes/cafebabe1234567890abcdef0987654321fedcba' + Remove-Item -Recurse -Force $custom -ErrorAction SilentlyContinue } It '-Refresh fetches every required template from raw.githubusercontent.com' { - Initialize-GitDefaults -Language 'CSharp' -Refresh -Force + Initialize-GitDefaults -Language 'CSharp' -Refresh -Force -GitattributesRef $script:r4Ref -GitignoreRef $script:r4Ref Assert-MockCalled -CommandName 'Invoke-WebRequest' -Scope It -Times 1 -ParameterFilter { $Uri -like '*alexkaratarakis/gitattributes*Common.gitattributes' } Assert-MockCalled -CommandName 'Invoke-WebRequest' -Scope It -Times 1 -ParameterFilter { $Uri -like '*alexkaratarakis/gitattributes*CSharp.gitattributes' } Assert-MockCalled -CommandName 'Invoke-WebRequest' -Scope It -Times 1 -ParameterFilter { $Uri -like '*github/gitignore*VisualStudio.gitignore' } @@ -392,7 +404,7 @@ Describe 'Copilot review #161 round 4: -Refresh implementation' { } It 'composes generated files from the fetched sentinel content (-Refresh writes through)' { - Initialize-GitDefaults -Language 'CSharp' -Refresh -Force + Initialize-GitDefaults -Language 'CSharp' -Refresh -Force -GitattributesRef $script:r4Ref -GitignoreRef $script:r4Ref (Get-Content '.gitattributes' -Raw) | Should -Match 'SENTINEL FOR Common.gitattributes' (Get-Content '.gitattributes' -Raw) | Should -Match 'SENTINEL FOR CSharp.gitattributes' (Get-Content '.gitignore' -Raw) | Should -Match 'SENTINEL FOR VisualStudio.gitignore' @@ -400,7 +412,7 @@ Describe 'Copilot review #161 round 4: -Refresh implementation' { } It 'header records "fetched from upstream" when -Refresh is used' { - Initialize-GitDefaults -Language 'CSharp' -Refresh -Force + Initialize-GitDefaults -Language 'CSharp' -Refresh -Force -GitattributesRef $script:r4Ref -GitignoreRef $script:r4Ref (Get-Content '.gitattributes' -Raw) | Should -Match 'Source mode: fetched from upstream' (Get-Content '.gitignore' -Raw) | Should -Match 'Source mode: fetched from upstream' } @@ -412,7 +424,7 @@ Describe 'Copilot review #161 round 4: -Refresh implementation' { } It 'overriding -GitattributesRef changes the URL the fetcher hits' { - Initialize-GitDefaults -Language 'CSharp' -GitattributesRef 'cafebabe1234567890abcdef0987654321fedcba' -Refresh -Force + Initialize-GitDefaults -Language 'CSharp' -GitattributesRef 'cafebabe1234567890abcdef0987654321fedcba' -GitignoreRef $script:r4Ref -Refresh -Force Assert-MockCalled -CommandName 'Invoke-WebRequest' -Scope It -Times 1 -ParameterFilter { $Uri -like '*alexkaratarakis/gitattributes/cafebabe1234567890abcdef0987654321fedcba/*' } @@ -564,3 +576,54 @@ Describe 'Copilot review #161 round 5: atomicity + -WhatIf cache safety' { } } } + +Describe 'Copilot review #161 round 6: hardening' { + Context 'CSharp detection includes modern .slnx solution files' { + It 'detects CSharp when only a .slnx file exists' { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("slnx-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $tmp | Out-Null + Set-Content -Path (Join-Path $tmp 'MyApp.slnx') -Value '' -NoNewline + try { + $detected = Get-GitDefaultsDetectedLanguages -Path $tmp + $detected | Should -Contain 'CSharp' + } finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } + } + } + + Context 'Refresh: cache-write failure warns but returns fetched content' { + BeforeEach { + $script:r6Repo = Join-Path ([System.IO.Path]::GetTempPath()) ("r6-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $script:r6Repo | Out-Null + & git -C $script:r6Repo init --quiet 2>&1 | Out-Null + Push-Location $script:r6Repo + $script:r6Ref = 'r6-' + [System.Guid]::NewGuid().ToString('N').Substring(0,12) + Mock -CommandName 'Invoke-WebRequest' -MockWith { + $name = Split-Path $Uri -Leaf + return [PSCustomObject]@{ Content = "# FRESH $name`n" } + } + # Force cache writes to throw by mocking the dir creation helper + # the function uses on first miss. Easiest: mock New-Item to throw + # when the path is under the cache root. + $script:cacheRoot = Get-GitDefaultsCacheRoot + Mock -CommandName 'New-Item' -ParameterFilter { + $Path -and ([string]$Path).StartsWith($script:cacheRoot) + } -MockWith { throw 'simulated unwritable cache' } + } + AfterEach { + Pop-Location + Remove-Item -Recurse -Force $script:r6Repo -ErrorAction SilentlyContinue + $a = Join-Path $script:cacheRoot "alexkaratarakis/gitattributes/$script:r6Ref" + $b = Join-Path $script:cacheRoot "github/gitignore/$script:r6Ref" + Remove-Item -Recurse -Force $a -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $b -ErrorAction SilentlyContinue + } + + It 'returns the freshly fetched content even when the cache cannot be updated' { + Initialize-GitDefaults -Language 'CSharp' -Refresh -Force -GitattributesRef $script:r6Ref -GitignoreRef $script:r6Ref -WarningAction SilentlyContinue + (Get-Content '.gitattributes' -Raw) | Should -Match 'FRESH Common.gitattributes' + (Get-Content '.gitignore' -Raw) | Should -Match 'FRESH VisualStudio.gitignore' + } + } +} diff --git a/Initialize-GitDefaults.ps1 b/Initialize-GitDefaults.ps1 index 4ccf165..ceacca2 100644 --- a/Initialize-GitDefaults.ps1 +++ b/Initialize-GitDefaults.ps1 @@ -244,28 +244,34 @@ function Get-GitDefaultsRefreshedContent { $url = "https://raw.githubusercontent.com/$Repo/$Ref/$FileName" try { $resp = Invoke-WebRequest -Uri $url -UseBasicParsing -ErrorAction Stop - $content = if ($resp.Content -is [byte[]]) { - [System.Text.Encoding]::UTF8.GetString($resp.Content) - } else { - [string]$resp.Content - } - # Respect -WhatIf: do not mutate the on-disk cache during a dry - # run. The fetched content is still returned so the caller can - # compose the "would-write" preview. Copilot review #161 round 5. - if ($PSCmdlet.ShouldProcess($cachePath, "Cache fetched template")) { - if (-not (Test-Path -LiteralPath $cacheParent)) { - New-Item -ItemType Directory -Force -Path $cacheParent | Out-Null - } - [System.IO.File]::WriteAllText($cachePath, $content, [System.Text.UTF8Encoding]::new($false)) - } - return $content } catch { + # Fetch failed -- fall back to a previously-cached copy if any. if (Test-Path -LiteralPath $cachePath) { Write-Warning "Refresh fetch failed ($($_.Exception.Message)); using cached copy at $cachePath." return (Get-Content -LiteralPath $cachePath -Raw) } throw "Refresh of '$FileName' from $url failed and no cached copy exists at $cachePath. Original error: $($_.Exception.Message)" } + $content = if ($resp.Content -is [byte[]]) { + [System.Text.Encoding]::UTF8.GetString($resp.Content) + } else { + [string]$resp.Content + } + # The fetch succeeded -- always return the fresh content even if we + # cannot persist it. Cache update failures must NOT discard a good + # download or be silently replaced with a stale cached copy. Copilot + # review #161 round 6. + if ($PSCmdlet.ShouldProcess($cachePath, "Cache fetched template")) { + try { + if (-not (Test-Path -LiteralPath $cacheParent)) { + New-Item -ItemType Directory -Force -Path $cacheParent -ErrorAction Stop | Out-Null + } + [System.IO.File]::WriteAllText($cachePath, $content, [System.Text.UTF8Encoding]::new($false)) + } catch { + Write-Warning "Refresh fetched '$FileName' but failed to update the cache at $cachePath ($($_.Exception.Message)); returning the freshly fetched content." + } + } + return $content } function Get-GitDefaultsTemplateContent { @@ -304,6 +310,8 @@ function New-GitDefaultsHeader { [Parameter(Mandatory)] [ValidateSet('gitattributes','gitignore')] [string] $Kind, [Parameter(Mandatory)] [string[]] $Language, [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]] $UpstreamSections, + [string] $GitattributesRef = $script:DefaultGitattributesRef, + [string] $GitignoreRef = $script:DefaultGitignoreRef, [string] $GibootstrapNote, [ValidateSet('bundled','fetched')] [string] $SourceMode = 'bundled' ) @@ -385,7 +393,7 @@ function New-GitAttributesContent { } } - $header = New-GitDefaultsHeader -Kind 'gitattributes' -Language $expanded -UpstreamSections $upstreamSections -SourceMode $(if ($Refresh) { 'fetched' } else { 'bundled' }) + $header = New-GitDefaultsHeader -Kind 'gitattributes' -Language $expanded -UpstreamSections $upstreamSections -GitattributesRef $GitattributesRef -GitignoreRef $GitignoreRef -SourceMode $(if ($Refresh) { 'fetched' } else { 'bundled' }) $body = [System.Collections.Generic.List[string]]::new() [void]$body.Add($header) @@ -440,7 +448,7 @@ function New-GitIgnoreContent { [void]$upstreamSections.Add('Global/Backup') } - $header = New-GitDefaultsHeader -Kind 'gitignore' -Language $expanded -UpstreamSections $upstreamSections -SourceMode $(if ($Refresh) { 'fetched' } else { 'bundled' }) + $header = New-GitDefaultsHeader -Kind 'gitignore' -Language $expanded -UpstreamSections $upstreamSections -GitattributesRef $GitattributesRef -GitignoreRef $GitignoreRef -SourceMode $(if ($Refresh) { 'fetched' } else { 'bundled' }) $body = [System.Collections.Generic.List[string]]::new() [void]$body.Add($header) @@ -561,7 +569,7 @@ function Get-GitDefaultsDetectedLanguages { param([string] $Path = (Get-Location).Path) $detected = [System.Collections.Generic.HashSet[string]]::new() - $hasCsproj = @(Get-ChildItem -Path $Path -Recurse -File -Include '*.csproj','*.sln' -ErrorAction SilentlyContinue -Depth 3).Count -gt 0 + $hasCsproj = @(Get-ChildItem -Path $Path -Recurse -File -Include '*.csproj','*.sln','*.slnx' -ErrorAction SilentlyContinue -Depth 3).Count -gt 0 $hasPs = @(Get-ChildItem -Path $Path -Recurse -File -Include '*.ps1','*.psm1','*.psd1' -ErrorAction SilentlyContinue -Depth 3).Count -gt 0 $hasTs = @(Get-ChildItem -Path $Path -Recurse -File -Include 'tsconfig.json','package.json' -ErrorAction SilentlyContinue -Depth 3).Count -gt 0 $hasAppSets = @(Get-ChildItem -Path $Path -Recurse -File -Filter 'appsettings*.json' -ErrorAction SilentlyContinue -Depth 3).Count -gt 0 diff --git a/Pull-SDLC.ai.Tests.ps1 b/Pull-SDLC.ai.Tests.ps1 index dccbf56..8542619 100644 --- a/Pull-SDLC.ai.Tests.ps1 +++ b/Pull-SDLC.ai.Tests.ps1 @@ -2243,3 +2243,44 @@ Describe 'Write-GitDefaultsHint (issue #160)' { $out.Trim() | Should -BeNullOrEmpty } } + +Describe 'Tombstone deletion staging (issue #160, Copilot review #161 round 6)' { + BeforeEach { + $script:tombRepo = Join-Path ([System.IO.Path]::GetTempPath()) ("tomb-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $script:tombRepo | Out-Null + & git -C $script:tombRepo init --quiet 2>&1 | Out-Null + & git -C $script:tombRepo config user.email 'test@example.com' + & git -C $script:tombRepo config user.name 'Test' + Set-Content -Path (Join-Path $script:tombRepo '.gitattributes.template') -Value "# legacy template`n" -NoNewline + & git -C $script:tombRepo add '.gitattributes.template' | Out-Null + & git -C $script:tombRepo commit -q -m 'seed legacy template' | Out-Null + # Simulate what Apply-UpstreamOp does for a D op: remove from + # the working tree. + Remove-Item -LiteralPath (Join-Path $script:tombRepo '.gitattributes.template') -Force + } + AfterEach { + Remove-Item -Recurse -Force $script:tombRepo -ErrorAction SilentlyContinue + } + + It 'stages a tracked-but-deleted tombstone path so the sync commit contains the deletion' { + Push-Location $script:tombRepo + try { + $p = '.gitattributes.template' + # Replicate the production staging logic exactly: + $addPaths = @() + if (Test-Path -LiteralPath $p) { + $addPaths += $p + } else { + & git ls-files --error-unmatch -- $p *>$null + if ($LASTEXITCODE -eq 0) { $addPaths += $p } + } + $addPaths | Should -Contain $p + $addArgs = @('add', '-A', '--') + $addPaths + & git @addArgs | Out-Null + # Now `git status --porcelain` must show the deletion as STAGED + # (uppercase D in the first column). + $status = & git status --porcelain + ($status -join "`n") | Should -Match '^D\s+\.gitattributes\.template' + } finally { Pop-Location } + } +} diff --git a/Pull-SDLC.ai.ps1 b/Pull-SDLC.ai.ps1 index d29e2fa..873e741 100644 --- a/Pull-SDLC.ai.ps1 +++ b/Pull-SDLC.ai.ps1 @@ -1621,12 +1621,21 @@ function Invoke-PullSDLC { Push-Location $RepoRoot try { - # Only include pathspecs that actually exist in the working tree -- - # `git add` aborts the entire operation on a missing pathspec. We add - # both upstream-managed paths and any merge-managed file we touched. + # Only include pathspecs that exist in the working tree OR are + # tracked in the index. `git add -A -- ` correctly stages + # deletions for tracked-but-now-missing paths (the tombstone + # migration relies on this), but aborts on a pathspec that has + # never been tracked and is also missing. Copilot review #161 + # round 6. $addPaths = @() foreach ($p in @($script:UpstreamManagedPaths + $script:SdlcSyncStateFile + $mergedPaths.ToArray())) { - if (Test-Path -LiteralPath $p) { $addPaths += $p } + if (Test-Path -LiteralPath $p) { + $addPaths += $p + continue + } + # Tracked-but-deleted: stage the deletion. + & git ls-files --error-unmatch -- $p *>$null + if ($LASTEXITCODE -eq 0) { $addPaths += $p } } if ($addPaths.Count -gt 0) { $addArgs = @('add', '-A', '--') + $addPaths From dd1bdd790144a3819843792dfee537ade4acd8c7 Mon Sep 17 00:00:00 2001 From: Mark Michaelis Date: Sat, 30 May 2026 21:17:34 -0700 Subject: [PATCH 09/10] fix(git-defaults): address Copilot review round 7 on #161 Three new threads from the round-7 Copilot review on PR #161: 1. jkb auto-detection of PowerShell from IntelliSDLC.ai's own .ps1 files: Get-GitDefaultsDetectedLanguages now excludes a known list of tool files (Pull-SDLC.ai.ps1, Initialize-GitDefaults.ps1, Cleanup-Worktree.ps1, Consolidate-Tasks.ps1, run.ps1, their *.Tests.ps1 siblings) and anything under .github/. Consumers who only have the synced tooling no longer get PowerShell pre-selected; adding a single consumer .ps1 alongside the tooling still detects. 2. jkg Write-GitDefaultsHint suppressed when Pull-SDLC.ai had merge-created a vanilla .gitignore: the hint now uses a new Test-GitDefaultsGeneratedMarker helper that scans the first 4 KB of each file for the "# Generated by Initialize-GitDefaults.ps1" header. Mere file presence is no longer treated as "the consumer has the language-aware file they need." Hint copy updated to "No language-aware ..." for honesty. 3. jkk in-process tombstone test was simulating production logic instead of exercising it: replaced with a real end-to-end test that builds a New-DiffReplayFixture where the anchor commit has .gitattributes.template and the next upstream commit deletes it, then invokes Invoke-PullSDLC and asserts (a) the file is gone from the working tree, (b) git status is clean (no unstaged deletion), and (c) the tip commit actually contains the D entry. Tests: 59 Initialize-GitDefaults tests + 23 targeted Pull-SDLC tests (added 2 detection-exclusion tests, 1 merge-created hint test, replaced 1 tombstone test with a real e2e). PSSA baseline unchanged: 3 / 146. Refs #160 Co-authored-by: GitHub Copilot --- Initialize-GitDefaults.Tests.ps1 | 34 ++++++++ Initialize-GitDefaults.ps1 | 22 ++++- Pull-SDLC.ai.Tests.ps1 | 142 ++++++++++++++++--------------- Pull-SDLC.ai.ps1 | 51 +++++++++-- 4 files changed, 172 insertions(+), 77 deletions(-) diff --git a/Initialize-GitDefaults.Tests.ps1 b/Initialize-GitDefaults.Tests.ps1 index 5e08184..2b6f228 100644 --- a/Initialize-GitDefaults.Tests.ps1 +++ b/Initialize-GitDefaults.Tests.ps1 @@ -627,3 +627,37 @@ Describe 'Copilot review #161 round 6: hardening' { } } } + +Describe 'Copilot review #161 round 7: detection ignores IntelliSDLC.ai tooling' { + It 'does NOT pre-select PowerShell when the only .ps1 files are IntelliSDLC.ai-installed tooling' { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("tool-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $tmp | Out-Null + try { + # Plant a .csproj so detection succeeds for something (proving + # detection itself runs); only IntelliSDLC.ai tooling files + # are present for PowerShell. + Set-Content -Path (Join-Path $tmp 'App.csproj') -Value '' -NoNewline + Set-Content -Path (Join-Path $tmp 'Pull-SDLC.ai.ps1') -Value '# tooling' -NoNewline + Set-Content -Path (Join-Path $tmp 'Initialize-GitDefaults.ps1') -Value '# tooling' -NoNewline + Set-Content -Path (Join-Path $tmp 'Cleanup-Worktree.ps1') -Value '# tooling' -NoNewline + $detected = Get-GitDefaultsDetectedLanguages -Path $tmp + $detected | Should -Contain 'CSharp' + $detected | Should -Not -Contain 'PowerShell' + } finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } + } + + It 'DOES pre-select PowerShell when the consumer adds their own .ps1 alongside the tooling' { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("tool-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $tmp | Out-Null + try { + Set-Content -Path (Join-Path $tmp 'Pull-SDLC.ai.ps1') -Value '# tooling' -NoNewline + Set-Content -Path (Join-Path $tmp 'Build.ps1') -Value '# consumer build' -NoNewline + $detected = Get-GitDefaultsDetectedLanguages -Path $tmp + $detected | Should -Contain 'PowerShell' + } finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } + } +} diff --git a/Initialize-GitDefaults.ps1 b/Initialize-GitDefaults.ps1 index ceacca2..2cd31f6 100644 --- a/Initialize-GitDefaults.ps1 +++ b/Initialize-GitDefaults.ps1 @@ -569,8 +569,28 @@ function Get-GitDefaultsDetectedLanguages { param([string] $Path = (Get-Location).Path) $detected = [System.Collections.Generic.HashSet[string]]::new() + + # Files installed by IntelliSDLC.ai itself that must NOT signal + # consumer language usage (Copilot review #161 round 7). A consumer + # who has only synced this tooling but writes no PowerShell of their + # own should not have PowerShell pre-selected. + $ownPsFiles = @( + 'Pull-SDLC.ai.ps1', 'Pull-SDLC.ai.Tests.ps1', + 'Initialize-GitDefaults.ps1', 'Initialize-GitDefaults.Tests.ps1', + 'Cleanup-Worktree.ps1', + 'Consolidate-Tasks.ps1', 'Consolidate-Tasks.Tests.ps1', + 'run.ps1', 'run.Tests.ps1' + ) + $isOwnPs = { param($f) + # Only filter at the repo root -- IntelliSDLC.ai tools never live + # in subdirectories of the consumer repo. + $rel = [System.IO.Path]::GetRelativePath($Path, $f.FullName) + ($ownPsFiles -contains $rel) -or ($rel -like '.github/*') + } + $hasCsproj = @(Get-ChildItem -Path $Path -Recurse -File -Include '*.csproj','*.sln','*.slnx' -ErrorAction SilentlyContinue -Depth 3).Count -gt 0 - $hasPs = @(Get-ChildItem -Path $Path -Recurse -File -Include '*.ps1','*.psm1','*.psd1' -ErrorAction SilentlyContinue -Depth 3).Count -gt 0 + $psFiles = @(Get-ChildItem -Path $Path -Recurse -File -Include '*.ps1','*.psm1','*.psd1' -ErrorAction SilentlyContinue -Depth 3 | Where-Object { -not (& $isOwnPs $_) }) + $hasPs = $psFiles.Count -gt 0 $hasTs = @(Get-ChildItem -Path $Path -Recurse -File -Include 'tsconfig.json','package.json' -ErrorAction SilentlyContinue -Depth 3).Count -gt 0 $hasAppSets = @(Get-ChildItem -Path $Path -Recurse -File -Filter 'appsettings*.json' -ErrorAction SilentlyContinue -Depth 3).Count -gt 0 diff --git a/Pull-SDLC.ai.Tests.ps1 b/Pull-SDLC.ai.Tests.ps1 index 8542619..32d1d58 100644 --- a/Pull-SDLC.ai.Tests.ps1 +++ b/Pull-SDLC.ai.Tests.ps1 @@ -2200,87 +2200,95 @@ Describe 'Initialize-GitDefaults migration (issue #160)' { } } -Describe 'Write-GitDefaultsHint (issue #160)' { - BeforeEach { - $script:hintRepo = Join-Path ([System.IO.Path]::GetTempPath()) ("hint-test-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) - New-Item -ItemType Directory -Path $script:hintRepo | Out-Null - } - AfterEach { - Remove-Item -Recurse -Force $script:hintRepo -ErrorAction SilentlyContinue - } - +Describe 'Write-GitDefaultsHint (issue #160)' { + BeforeEach { + $script:hintRepo = Join-Path ([System.IO.Path]::GetTempPath()) ("hint-test-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $script:hintRepo | Out-Null + $script:marker = "# Generated by Initialize-GitDefaults.ps1 on 2025-01-01T00:00:00Z`n" + } + AfterEach { + Remove-Item -Recurse -Force $script:hintRepo -ErrorAction SilentlyContinue + } + It 'prints the both-missing hint exactly once when neither file exists' { $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String ([regex]::Matches($out, 'Initialize-GitDefaults\.ps1')).Count | Should -Be 1 - $out | Should -Match 'No \.gitattributes or \.gitignore found' + $out | Should -Match 'No language-aware \.gitattributes/\.gitignore found' } - It 'prints the gitattributes-only hint with -IncludeGitignore:$false when only .gitattributes is missing (Copilot review #161 round 2)' { - New-Item -ItemType File -Path (Join-Path $script:hintRepo '.gitignore') | Out-Null + It 'prints the gitattributes-only hint with -IncludeGitignore:$false when only .gitignore was generated (Copilot review #161 round 2)' { + Set-Content -Path (Join-Path $script:hintRepo '.gitignore') -Value $script:marker -NoNewline $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String - $out | Should -Match 'No \.gitattributes found' + $out | Should -Match 'No language-aware \.gitattributes found' $out | Should -Match '-IncludeGitignore:\$false' $out | Should -Match 'your existing \.gitignore is untouched' } - It 'prints the gitignore-only hint with -IncludeGitattributes:$false when only .gitignore is missing (Copilot review #161 round 2)' { - New-Item -ItemType File -Path (Join-Path $script:hintRepo '.gitattributes') | Out-Null + It 'prints the gitignore-only hint with -IncludeGitattributes:$false when only .gitattributes was generated (Copilot review #161 round 2)' { + Set-Content -Path (Join-Path $script:hintRepo '.gitattributes') -Value $script:marker -NoNewline $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String - $out | Should -Match 'No \.gitignore found' + $out | Should -Match 'No language-aware \.gitignore found' $out | Should -Match '-IncludeGitattributes:\$false' } - It 'mentions both upstream sources in the both-missing hint' { - $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String - $out | Should -Match 'alexkaratarakis/gitattributes' - $out | Should -Match 'github/gitignore' - } - - It 'prints nothing when both files already exist' { - New-Item -ItemType File -Path (Join-Path $script:hintRepo '.gitattributes') | Out-Null - New-Item -ItemType File -Path (Join-Path $script:hintRepo '.gitignore') | Out-Null - $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String - $out.Trim() | Should -BeNullOrEmpty - } + It 'mentions both upstream sources in the both-missing hint' { + $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String + $out | Should -Match 'alexkaratarakis/gitattributes' + $out | Should -Match 'github/gitignore' + } + + It 'prints nothing when both files contain the Initialize-GitDefaults marker' { + Set-Content -Path (Join-Path $script:hintRepo '.gitattributes') -Value $script:marker -NoNewline + Set-Content -Path (Join-Path $script:hintRepo '.gitignore') -Value $script:marker -NoNewline + $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String + $out.Trim() | Should -BeNullOrEmpty + } + + It 'still prompts when .gitignore was merge-created by Pull-SDLC.ai but lacks the generator marker (Copilot review #161 round 7)' { + # Simulates the fresh-sync scenario: Merge-FileFromUpstream creates + # .gitignore from $script:MergePaths with vanilla content; the file + # exists but was NOT produced by Initialize-GitDefaults.ps1. + Set-Content -Path (Join-Path $script:hintRepo '.gitignore') -Value "node_modules/`n" -NoNewline + $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String + $out | Should -Match 'No language-aware \.gitattributes/\.gitignore found' + } } -Describe 'Tombstone deletion staging (issue #160, Copilot review #161 round 6)' { - BeforeEach { - $script:tombRepo = Join-Path ([System.IO.Path]::GetTempPath()) ("tomb-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) - New-Item -ItemType Directory -Path $script:tombRepo | Out-Null - & git -C $script:tombRepo init --quiet 2>&1 | Out-Null - & git -C $script:tombRepo config user.email 'test@example.com' - & git -C $script:tombRepo config user.name 'Test' - Set-Content -Path (Join-Path $script:tombRepo '.gitattributes.template') -Value "# legacy template`n" -NoNewline - & git -C $script:tombRepo add '.gitattributes.template' | Out-Null - & git -C $script:tombRepo commit -q -m 'seed legacy template' | Out-Null - # Simulate what Apply-UpstreamOp does for a D op: remove from - # the working tree. - Remove-Item -LiteralPath (Join-Path $script:tombRepo '.gitattributes.template') -Force - } - AfterEach { - Remove-Item -Recurse -Force $script:tombRepo -ErrorAction SilentlyContinue - } - - It 'stages a tracked-but-deleted tombstone path so the sync commit contains the deletion' { - Push-Location $script:tombRepo - try { - $p = '.gitattributes.template' - # Replicate the production staging logic exactly: - $addPaths = @() - if (Test-Path -LiteralPath $p) { - $addPaths += $p - } else { - & git ls-files --error-unmatch -- $p *>$null - if ($LASTEXITCODE -eq 0) { $addPaths += $p } - } - $addPaths | Should -Contain $p - $addArgs = @('add', '-A', '--') + $addPaths - & git @addArgs | Out-Null - # Now `git status --porcelain` must show the deletion as STAGED - # (uppercase D in the first column). - $status = & git status --porcelain - ($status -join "`n") | Should -Match '^D\s+\.gitattributes\.template' - } finally { Pop-Location } - } +Describe 'Tombstone deletion staging (issue #160, Copilot review #161 round 6+7)' { + BeforeEach { + $script:tombFx = Join-Path $TestDrive ("tomb-" + [guid]::NewGuid().ToString('N')) + } + + It 'Invoke-PullSDLC stages and commits the tombstone deletion when the consumer had the legacy .gitattributes.template tracked' { + # Build an upstream that HAD .gitattributes.template at the anchor + # commit and removes it at the next commit (the tombstone scenario). + $fx = New-DiffReplayFixture -Root $script:tombFx ` + -Seed { '# legacy template' | Out-File -Encoding utf8 .gitattributes.template -NoNewline } ` + -Tweak { Remove-Item .gitattributes.template } + + # Seed the consumer state file with the anchor so we skip bootstrap. + Set-SdlcSyncState -RepoRoot $fx.Consumer -Remote 'sdlc.ai' -Ref 'main' -Commit $fx.AnchorSha + Push-Location $fx.Consumer + try { git add .sdlc-ai-sync.json; git commit -q -m 'seed state' } finally { Pop-Location } + + # Confirm the consumer started with the legacy file tracked. + Test-Path (Join-Path $fx.Consumer '.gitattributes.template') | Should -BeTrue + + $rc = Invoke-PullSDLC -RepoRoot $fx.Consumer -RemoteName 'sdlc.ai' -NoFetch + $rc | Should -Be 0 + + # The file is gone from the working tree... + Test-Path (Join-Path $fx.Consumer '.gitattributes.template') | Should -BeFalse + + # ...and the deletion landed in the sync commit (NOT left dangling + # as an unstaged change). Copilot review #161 round 6 was that the + # staging loop skipped tracked-but-deleted paths. + Push-Location $fx.Consumer + try { + $unstaged = (git status --porcelain) -join "`n" + $unstaged | Should -Not -Match '\.gitattributes\.template' + $deletedInCommit = git log -1 --diff-filter=D --name-only --pretty=format: + ($deletedInCommit -join "`n") | Should -Match '\.gitattributes\.template' + } finally { Pop-Location } + } } diff --git a/Pull-SDLC.ai.ps1 b/Pull-SDLC.ai.ps1 index 873e741..c9772fd 100644 --- a/Pull-SDLC.ai.ps1 +++ b/Pull-SDLC.ai.ps1 @@ -1361,17 +1361,50 @@ function Write-GitDefaultsHint { [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'User-facing CLI hint matching the surrounding banner convention.')] param([Parameter(Mandatory)][string]$RepoRoot) - $missingAttrs = -not (Test-Path -LiteralPath (Join-Path $RepoRoot '.gitattributes')) - $missingIgnore = -not (Test-Path -LiteralPath (Join-Path $RepoRoot '.gitignore')) - if (-not ($missingAttrs -or $missingIgnore)) { return } - if ($missingAttrs -and $missingIgnore) { - Write-Host 'No .gitattributes or .gitignore found. Run ./Initialize-GitDefaults.ps1 -Language -Force to scaffold both (uses alexkaratarakis/gitattributes + github/gitignore at pinned SHAs).' -ForegroundColor Yellow - } - elseif ($missingAttrs) { - Write-Host 'No .gitattributes found. Run ./Initialize-GitDefaults.ps1 -Language -IncludeGitignore:$false -Force (your existing .gitignore is untouched).' -ForegroundColor Yellow + # File-presence alone is misleading because Pull-SDLC.ai sync may have + # merge-created .gitignore from $script:MergePaths before this hint + # runs. The honest signal is whether the file was produced by + # Initialize-GitDefaults.ps1 (which stamps the "Generated by" header). + # Copilot review #161 round 7. + $marker = '# Generated by Initialize-GitDefaults.ps1' + $needsAttrs = -not (Test-GitDefaultsGeneratedMarker -Path (Join-Path $RepoRoot '.gitattributes') -Marker $marker) + $needsIgnore = -not (Test-GitDefaultsGeneratedMarker -Path (Join-Path $RepoRoot '.gitignore') -Marker $marker) + if (-not ($needsAttrs -or $needsIgnore)) { return } + if ($needsAttrs -and $needsIgnore) { + Write-Host 'No language-aware .gitattributes/.gitignore found. Run ./Initialize-GitDefaults.ps1 -Language -Force to generate both (uses alexkaratarakis/gitattributes + github/gitignore at pinned SHAs).' -ForegroundColor Yellow + } + elseif ($needsAttrs) { + Write-Host 'No language-aware .gitattributes found. Run ./Initialize-GitDefaults.ps1 -Language -IncludeGitignore:$false -Force (your existing .gitignore is untouched).' -ForegroundColor Yellow } else { - Write-Host 'No .gitignore found. Run ./Initialize-GitDefaults.ps1 -Language -IncludeGitattributes:$false -Force (your existing .gitattributes is untouched).' -ForegroundColor Yellow + Write-Host 'No language-aware .gitignore found. Run ./Initialize-GitDefaults.ps1 -Language -IncludeGitattributes:$false -Force (your existing .gitattributes is untouched).' -ForegroundColor Yellow + } +} + +function Test-GitDefaultsGeneratedMarker { + <# + .SYNOPSIS + Returns $true iff the file at -Path exists AND its first few KB + contain the Initialize-GitDefaults.ps1 "Generated by" header. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Marker + ) + if (-not (Test-Path -LiteralPath $Path)) { return $false } + try { + # Read only the first 4 KB; the header lives in the first ~10 lines. + $fs = [System.IO.File]::OpenRead($Path) + try { + $buf = New-Object byte[] 4096 + $n = $fs.Read($buf, 0, $buf.Length) + $head = [System.Text.Encoding]::UTF8.GetString($buf, 0, $n) + } finally { $fs.Dispose() } + return $head.Contains($Marker) + } catch { + return $false } } From f242da1a4f2aae162026bac37fec147bc898763b Mon Sep 17 00:00:00 2001 From: Mark Michaelis Date: Sat, 30 May 2026 21:36:11 -0700 Subject: [PATCH 10/10] fix(git-defaults): address Copilot review round 8 on #161 Two new threads from the round-8 Copilot review on PR #161: 1. k2H Windows path-separator gap: GetRelativePath returns backslash-separated paths on Windows, so the round-7 `.github/*` filter never matched tooling files under `.github\` and PowerShell could still be wrongly pre-selected. The filter now normalizes the relative path with `-replace '\\','/'` before the like-match. 2. k2P round-7's "Generated by" header check would mistreat every hand-maintained .gitattributes/.gitignore as missing and badger consumers every sync. Replaced with file-presence PLUS an explicit `-NewlyMergedFiles` signal from Pull-SDLC.ai: Invoke-PullSDLC now snapshots which paths in $script:MergePaths existed BEFORE the union-merge step and passes the set of freshly-created ones (e.g. a vanilla .gitignore that the sync just merge-created from upstream) to Write-NextStepsBanner -> Write-GitDefaultsHint. The hint fires when a file is missing OR was just merge-created on this sync; pre-existing hand-maintained files are left alone. Test-GitDefaultsGeneratedMarker helper removed. Tests: 60 Initialize-GitDefaults tests (added 1 .github\ backslash test) + targeted Pull-SDLC tests including the full 10-test Invoke-PullSDLC end-to-end suite and 7 Write-GitDefaultsHint cases (added 2 round-8 cases: "merge-created file still prompts" and "hand-maintained files leave the consumer alone") all GREEN. PSSA baseline unchanged: 3 / 146. Refs #160 Co-authored-by: GitHub Copilot --- Initialize-GitDefaults.Tests.ps1 | 18 ++++++++ Initialize-GitDefaults.ps1 | 6 ++- Pull-SDLC.ai.Tests.ps1 | 36 ++++++++++------ Pull-SDLC.ai.ps1 | 73 +++++++++++++++----------------- 4 files changed, 78 insertions(+), 55 deletions(-) diff --git a/Initialize-GitDefaults.Tests.ps1 b/Initialize-GitDefaults.Tests.ps1 index 2b6f228..88c4ba2 100644 --- a/Initialize-GitDefaults.Tests.ps1 +++ b/Initialize-GitDefaults.Tests.ps1 @@ -660,4 +660,22 @@ Describe 'Copilot review #161 round 7: detection ignores IntelliSDLC.ai tooling' Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue } } + + It 'excludes .github\ tooling on Windows (path-separator normalization, Copilot review #161 round 8)' { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("gh-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $tmp | Out-Null + try { + # Plant a .ps1 ONLY under .github\ (subdirectory) -- this is + # IntelliSDLC.ai upstream-managed tooling and must NOT trigger + # PowerShell detection. GetRelativePath returns backslashes on + # Windows, so the like-match must accept both separators. + $gh = Join-Path $tmp '.github' + New-Item -ItemType Directory -Path $gh | Out-Null + Set-Content -Path (Join-Path $gh 'helper.ps1') -Value '# upstream helper' -NoNewline + $detected = Get-GitDefaultsDetectedLanguages -Path $tmp + $detected | Should -Not -Contain 'PowerShell' + } finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } + } } diff --git a/Initialize-GitDefaults.ps1 b/Initialize-GitDefaults.ps1 index 2cd31f6..d32afb9 100644 --- a/Initialize-GitDefaults.ps1 +++ b/Initialize-GitDefaults.ps1 @@ -583,8 +583,10 @@ function Get-GitDefaultsDetectedLanguages { ) $isOwnPs = { param($f) # Only filter at the repo root -- IntelliSDLC.ai tools never live - # in subdirectories of the consumer repo. - $rel = [System.IO.Path]::GetRelativePath($Path, $f.FullName) + # in subdirectories of the consumer repo. Normalize the path + # separator: GetRelativePath emits backslashes on Windows so the + # `.github/` match must accept both. Copilot review #161 round 8. + $rel = [System.IO.Path]::GetRelativePath($Path, $f.FullName) -replace '\\','/' ($ownPsFiles -contains $rel) -or ($rel -like '.github/*') } diff --git a/Pull-SDLC.ai.Tests.ps1 b/Pull-SDLC.ai.Tests.ps1 index 32d1d58..a93624a 100644 --- a/Pull-SDLC.ai.Tests.ps1 +++ b/Pull-SDLC.ai.Tests.ps1 @@ -2204,7 +2204,6 @@ Describe 'Write-GitDefaultsHint (issue #160)' { BeforeEach { $script:hintRepo = Join-Path ([System.IO.Path]::GetTempPath()) ("hint-test-" + [System.Guid]::NewGuid().ToString('N').Substring(0,8)) New-Item -ItemType Directory -Path $script:hintRepo | Out-Null - $script:marker = "# Generated by Initialize-GitDefaults.ps1 on 2025-01-01T00:00:00Z`n" } AfterEach { Remove-Item -Recurse -Force $script:hintRepo -ErrorAction SilentlyContinue @@ -2216,16 +2215,16 @@ Describe 'Write-GitDefaultsHint (issue #160)' { $out | Should -Match 'No language-aware \.gitattributes/\.gitignore found' } - It 'prints the gitattributes-only hint with -IncludeGitignore:$false when only .gitignore was generated (Copilot review #161 round 2)' { - Set-Content -Path (Join-Path $script:hintRepo '.gitignore') -Value $script:marker -NoNewline + It 'prints the gitattributes-only hint with -IncludeGitignore:$false when only .gitattributes is missing (Copilot review #161 round 2)' { + New-Item -ItemType File -Path (Join-Path $script:hintRepo '.gitignore') | Out-Null $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String $out | Should -Match 'No language-aware \.gitattributes found' $out | Should -Match '-IncludeGitignore:\$false' $out | Should -Match 'your existing \.gitignore is untouched' } - It 'prints the gitignore-only hint with -IncludeGitattributes:$false when only .gitattributes was generated (Copilot review #161 round 2)' { - Set-Content -Path (Join-Path $script:hintRepo '.gitattributes') -Value $script:marker -NoNewline + It 'prints the gitignore-only hint with -IncludeGitattributes:$false when only .gitignore is missing (Copilot review #161 round 2)' { + New-Item -ItemType File -Path (Join-Path $script:hintRepo '.gitattributes') | Out-Null $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String $out | Should -Match 'No language-aware \.gitignore found' $out | Should -Match '-IncludeGitattributes:\$false' @@ -2237,21 +2236,30 @@ Describe 'Write-GitDefaultsHint (issue #160)' { $out | Should -Match 'github/gitignore' } - It 'prints nothing when both files contain the Initialize-GitDefaults marker' { - Set-Content -Path (Join-Path $script:hintRepo '.gitattributes') -Value $script:marker -NoNewline - Set-Content -Path (Join-Path $script:hintRepo '.gitignore') -Value $script:marker -NoNewline + It 'prints nothing when both files already exist (hand-maintained: Copilot review #161 round 8)' { + New-Item -ItemType File -Path (Join-Path $script:hintRepo '.gitattributes') | Out-Null + New-Item -ItemType File -Path (Join-Path $script:hintRepo '.gitignore') | Out-Null $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String $out.Trim() | Should -BeNullOrEmpty } - It 'still prompts when .gitignore was merge-created by Pull-SDLC.ai but lacks the generator marker (Copilot review #161 round 7)' { - # Simulates the fresh-sync scenario: Merge-FileFromUpstream creates - # .gitignore from $script:MergePaths with vanilla content; the file - # exists but was NOT produced by Initialize-GitDefaults.ps1. - Set-Content -Path (Join-Path $script:hintRepo '.gitignore') -Value "node_modules/`n" -NoNewline - $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo 6>&1 | Out-String + It 'still prompts when Pull-SDLC.ai JUST merge-created .gitignore from upstream on this sync (Copilot review #161 round 7+8)' { + # File exists now (Pull-SDLC.ai dropped a vanilla copy) but the + # caller signals it was freshly merge-created, so the hint must + # still fire. + New-Item -ItemType File -Path (Join-Path $script:hintRepo '.gitignore') | Out-Null + $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo -NewlyMergedFiles @('.gitignore') 6>&1 | Out-String $out | Should -Match 'No language-aware \.gitattributes/\.gitignore found' } + + It 'does NOT prompt when the consumer already has a hand-maintained .gitignore and .gitattributes (round 8)' { + New-Item -ItemType File -Path (Join-Path $script:hintRepo '.gitattributes') | Out-Null + New-Item -ItemType File -Path (Join-Path $script:hintRepo '.gitignore') | Out-Null + # Empty NewlyMergedFiles means Pull-SDLC.ai did not create either + # file on this sync; they were pre-existing. + $out = Write-GitDefaultsHint -RepoRoot $script:hintRepo -NewlyMergedFiles @() 6>&1 | Out-String + $out.Trim() | Should -BeNullOrEmpty + } } Describe 'Tombstone deletion staging (issue #160, Copilot review #161 round 6+7)' { diff --git a/Pull-SDLC.ai.ps1 b/Pull-SDLC.ai.ps1 index c9772fd..9754a6c 100644 --- a/Pull-SDLC.ai.ps1 +++ b/Pull-SDLC.ai.ps1 @@ -1317,7 +1317,8 @@ function Write-NextStepsBanner { param( [Parameter(Mandatory)][string]$RepoRoot, [Parameter(Mandatory)][AllowEmptyString()][string]$AnchorSource, - [string[]]$ScaffoldedFiles = @() + [string[]]$ScaffoldedFiles = @(), + [string[]]$NewlyMergedFiles = @() ) if ($AnchorSource -notin @('bootstrap', 'auto-bootstrap')) { return } @@ -1345,7 +1346,7 @@ function Write-NextStepsBanner { Write-Host ' gh repo create --source=. --public --push' -ForegroundColor Cyan } - Write-GitDefaultsHint -RepoRoot $RepoRoot + Write-GitDefaultsHint -RepoRoot $RepoRoot -NewlyMergedFiles $NewlyMergedFiles } function Write-GitDefaultsHint { @@ -1360,15 +1361,22 @@ function Write-GitDefaultsHint { #> [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'User-facing CLI hint matching the surrounding banner convention.')] - param([Parameter(Mandatory)][string]$RepoRoot) - # File-presence alone is misleading because Pull-SDLC.ai sync may have - # merge-created .gitignore from $script:MergePaths before this hint - # runs. The honest signal is whether the file was produced by - # Initialize-GitDefaults.ps1 (which stamps the "Generated by" header). - # Copilot review #161 round 7. - $marker = '# Generated by Initialize-GitDefaults.ps1' - $needsAttrs = -not (Test-GitDefaultsGeneratedMarker -Path (Join-Path $RepoRoot '.gitattributes') -Marker $marker) - $needsIgnore = -not (Test-GitDefaultsGeneratedMarker -Path (Join-Path $RepoRoot '.gitignore') -Marker $marker) + param( + [Parameter(Mandatory)][string]$RepoRoot, + # Files that Pull-SDLC.ai's union-merge step JUST created from + # scratch on this sync (i.e. Pull-SDLC.ai dropped a vanilla copy + # in place, e.g. .gitignore from $script:MergePaths). Even though + # the file now exists on disk, the consumer never explicitly chose + # a language-aware ruleset, so we should still prompt for + # Initialize-GitDefaults.ps1. Pre-existing hand-maintained files + # do NOT appear here and so do NOT trigger the hint. Copilot + # review #161 round 8. + [string[]]$NewlyMergedFiles = @() + ) + $attrsPath = Join-Path $RepoRoot '.gitattributes' + $ignorePath = Join-Path $RepoRoot '.gitignore' + $needsAttrs = (-not (Test-Path -LiteralPath $attrsPath)) -or ($NewlyMergedFiles -contains '.gitattributes') + $needsIgnore = (-not (Test-Path -LiteralPath $ignorePath)) -or ($NewlyMergedFiles -contains '.gitignore') if (-not ($needsAttrs -or $needsIgnore)) { return } if ($needsAttrs -and $needsIgnore) { Write-Host 'No language-aware .gitattributes/.gitignore found. Run ./Initialize-GitDefaults.ps1 -Language -Force to generate both (uses alexkaratarakis/gitattributes + github/gitignore at pinned SHAs).' -ForegroundColor Yellow @@ -1381,33 +1389,6 @@ function Write-GitDefaultsHint { } } -function Test-GitDefaultsGeneratedMarker { - <# - .SYNOPSIS - Returns $true iff the file at -Path exists AND its first few KB - contain the Initialize-GitDefaults.ps1 "Generated by" header. - #> - [CmdletBinding()] - [OutputType([bool])] - param( - [Parameter(Mandatory)][string]$Path, - [Parameter(Mandatory)][string]$Marker - ) - if (-not (Test-Path -LiteralPath $Path)) { return $false } - try { - # Read only the first 4 KB; the header lives in the first ~10 lines. - $fs = [System.IO.File]::OpenRead($Path) - try { - $buf = New-Object byte[] 4096 - $n = $fs.Read($buf, 0, $buf.Length) - $head = [System.Text.Encoding]::UTF8.GetString($buf, 0, $n) - } finally { $fs.Dispose() } - return $head.Contains($Marker) - } catch { - return $false - } -} - function Invoke-SelfRefreshGate { <# .SYNOPSIS @@ -1638,15 +1619,29 @@ function Invoke-PullSDLC { Write-Host "Applied $($ops.Count) ops." -ForegroundColor Green } + # Capture pre-merge presence so we can tell Write-GitDefaultsHint + # which merge-managed files Pull-SDLC.ai created from scratch on + # this sync. Existing hand-maintained files must NOT trigger the + # hint, but a freshly merge-created vanilla file should still prompt + # the consumer to generate the language-aware version. Copilot + # review #161 round 8. + $mergePathPreExist = @{} + foreach ($mp in $script:MergePaths) { + $mergePathPreExist[$mp] = Test-Path -LiteralPath (Join-Path $RepoRoot $mp) + } # Union-merge merge-managed files (today: .gitignore). Done after the # main op loop so the merge always sees the latest upstream blob. The # merge is idempotent -- a no-op when local already contains every # upstream entry. $mergedPaths = New-Object System.Collections.Generic.List[string] + $newlyMergedFiles = New-Object System.Collections.Generic.List[string] foreach ($mp in $script:MergePaths) { if (Merge-FileFromUpstream -Path $mp -Ref $mergeRef -RepoRoot $RepoRoot) { $mergedPaths.Add($mp) | Out-Null Write-Host "Merged upstream entries into $mp" -ForegroundColor Green + if (-not $mergePathPreExist[$mp]) { + $newlyMergedFiles.Add($mp) | Out-Null + } } } @@ -1714,7 +1709,7 @@ function Invoke-PullSDLC { } } - Write-NextStepsBanner -RepoRoot $RepoRoot -AnchorSource $anchorInfo.Source -ScaffoldedFiles $scaffolded + Write-NextStepsBanner -RepoRoot $RepoRoot -AnchorSource $anchorInfo.Source -ScaffoldedFiles $scaffolded -NewlyMergedFiles $newlyMergedFiles.ToArray() return 0 }