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/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/SOURCES.md b/.github/templates/git-defaults/SOURCES.md new file mode 100644 index 0000000..6fcc1bb --- /dev/null +++ b/.github/templates/git-defaults/SOURCES.md @@ -0,0 +1,109 @@ +# 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 + +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 = '' +$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` / `$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 +`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/.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/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.Tests.ps1 b/Initialize-GitDefaults.Tests.ps1 new file mode 100644 index 0000000..88c4ba2 --- /dev/null +++ b/Initialize-GitDefaults.Tests.ps1 @@ -0,0 +1,681 @@ +#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' + } + + 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 '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 + } + } +} + +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' + } +} + +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 + # 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) + # 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 + 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 -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' } + 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 -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' + (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 -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' + } + + 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' -GitignoreRef $script:r4Ref -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 + } +} + +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 + } + } +} + +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' + } + } +} + +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 + } + } + + 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 new file mode 100644 index 0000000..d32afb9 --- /dev/null +++ b/Initialize-GitDefaults.ps1 @@ -0,0 +1,739 @@ +<# +.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. 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 + 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. + +.PARAMETER IncludeGitattributes + Compose `.gitattributes`. Default: $true. + +.PARAMETER GitattributesRef + 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 used when fetching with `-Refresh`. Same + bundled-vs-fetched semantics as `-GitattributesRef`. + +.PARAMETER Refresh + 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 + 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 + +.EXAMPLE + ./Initialize-GitDefaults.ps1 -Language ASP.NET,TypeScript -Force + # ASP.NET expands to {ASP.NET, CSharp}. + +.EXAMPLE + ./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.')] +[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, + [switch] $IncludeGitattributes, + [string] $GitattributesRef = 'fddc586cf0f10ec4485028d0d2dd6f73197a4258', + [string] $GitignoreRef = 'dcc0fc7bc2b5ba480cf117ad1be31bafceeaff46', + [switch] $Refresh, + [switch] $Force +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ----- 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' } + '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 = @' +# 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 +*.ps1xml text eol=crlf +*.pssc text eol=crlf +*.ps1 linguist-language=PowerShell +*.psm1 linguist-language=PowerShell +*.psd1 linguist-language=PowerShell +'@ + +$script:CuratedPowerShellGitignore = @' +# 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 +'@ + +# Authority labels surfaced in file headers and SOURCES.md. +$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 { + <# + .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-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(SupportsShouldProcess)] + [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 + $url = "https://raw.githubusercontent.com/$Repo/$Ref/$FileName" + try { + $resp = Invoke-WebRequest -Uri $url -UseBasicParsing -ErrorAction Stop + } 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 { + <# + .SYNOPSIS + 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, + [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, or pass -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] $GitattributesRef = $script:DefaultGitattributesRef, + [string] $GitignoreRef = $script:DefaultGitignoreRef, + [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)' } + [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) { + 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('') + 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, + [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') + $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 -GitattributesRef $GitattributesRef -GitignoreRef $GitignoreRef -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' @fetchSplat)) + + $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 @fetchSplat)) + } 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, + [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() + 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) + } + } + # 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 -GitattributesRef $GitattributesRef -GitignoreRef $GitignoreRef -SourceMode $(if ($Refresh) { 'fetched' } else { 'bundled' }) + + $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 @fetchSplat)) + } elseif ($lang -eq 'PowerShell') { + [void]$body.Add((New-GitDefaultsSectionDivider -Section 'PowerShell' -SourceLabel 'curated in-script')) + [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 @fetchSplat)) + } + 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`, 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])] + param( + [Parameter(Mandatory)] [string] $Path + ) + if (-not (Test-Path -LiteralPath $Path)) { return $null } + $bak = "$Path.bak" + if (Test-Path -LiteralPath $bak) { + # 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 + } + 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 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() + + # 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. 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/*') + } + + $hasCsproj = @(Get-ChildItem -Path $Path -Recurse -File -Include '*.csproj','*.sln','*.slnx' -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 + + 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 + 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) { + $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 + 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 ', ')) + } + } + + $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) { + $pending['.gitattributes'] = New-GitAttributesContent -Language $expanded @composeSplat + } + if ($IncludeGitignore) { + $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 + } + } +} + +# 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 + diff --git a/Pull-SDLC.ai.Tests.ps1 b/Pull-SDLC.ai.Tests.ps1 index b1eaa8f..a93624a 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,123 @@ 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 '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)' { + 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 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 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 + $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 .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' + } + + 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 (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 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)' { + 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 f396c61..9754a6c 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 @@ -174,7 +173,19 @@ $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', + # 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 @@ -1298,13 +1309,16 @@ 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( [Parameter(Mandatory)][string]$RepoRoot, [Parameter(Mandatory)][AllowEmptyString()][string]$AnchorSource, - [string[]]$ScaffoldedFiles = @() + [string[]]$ScaffoldedFiles = @(), + [string[]]$NewlyMergedFiles = @() ) if ($AnchorSource -notin @('bootstrap', 'auto-bootstrap')) { return } @@ -1331,6 +1345,48 @@ 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 -NewlyMergedFiles $NewlyMergedFiles +} + +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, + # 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 + } + 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 language-aware .gitignore found. Run ./Initialize-GitDefaults.ps1 -Language -IncludeGitattributes:$false -Force (your existing .gitattributes is untouched).' -ForegroundColor Yellow + } } function Invoke-SelfRefreshGate { @@ -1563,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 + } } } @@ -1579,12 +1649,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 @@ -1630,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 } 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