<# .SYNOPSIS Counts the developers who changed code that calls IntentSharp, from the git history on this machine. .DESCRIPTION For the yearly developer count that an IntentSharp Developer or Business license asks for. The script reads git history on this machine, prints its result to the console, writes nothing and sends nothing. It needs PowerShell 7.2 or later and git, on Windows, macOS or Linux. In each repository it finds the project files that reference the packages: a PackageReference whose Include names IntentSharp, IntentSharp.Catalog or IntentSharp.Extensions.AI, alone or in a semicolon-separated list, with or without a Version attribute, in the *.csproj, *.fsproj, *.vbproj, *.props and *.targets files that git tracks. A reference inside an XML comment does not count. The code it counts is each such project's folder and everything under it, plus the folders given with -Include. It then lists every commit in the period that changed that code, leaving out merge commits (the people who wrote the merged commits are counted, not the person who merged them), takes each commit's author name and email as git resolves them through the repository's .mailmap, and groups the commits by email across every repository given. The output is a table with one row per person (name, email, first and last commit date in the period, number of commits), then the line "Developers who changed IntentSharp code between and : N", where N is the number of distinct people. Dates are printed as yyyy-MM-dd whatever the console's language. A person who builds or debugs the code but never commits is not in the table and still counts; add them to N yourself. The script only reads from git. It never writes a file, never changes the repository and never makes a network call. It reads the history of the branch that is checked out in each repository. .PARAMETER Path One or more repository roots: the folder that holds .git. Default: the current directory. A folder that is not in a git work tree stops the script with an error, and so does a folder inside a repository that is not its root; the error names the root to pass. .PARAMETER Since The first day of the period, inclusive. Default: 12 months before today. The time of day is ignored: a commit counts when its author date, as git records it, falls on this day or later. .PARAMETER Until The last day of the period, inclusive. Default: today. The time of day is ignored: a commit counts when its author date falls on this day or earlier. .PARAMETER Include Extra folders to count as IntentSharp code, besides the folders of the projects that reference the packages: for intent files or other code kept outside the project folders. Each is relative to a repository root, or absolute; a relative folder is used in every repository that has it. A folder that no longer exists is accepted when the repository's history has it: a project that was moved or renamed in the period is found at its current folder only, so give its old folder here and the commits before the move count too. A folder that is in none of the repositories, on disk or in the history, is an error. .PARAMETER Quiet Print only the number. .EXAMPLE pwsh ./count-developers.ps1 -Path C:\src\my-app The developers who changed code calling IntentSharp in that repository in the last 12 months. .EXAMPLE pwsh ./count-developers.ps1 -Path C:\src\app-one, C:\src\app-two -Since 2025-10-01 -Until 2026-09-30 Two repositories, a different period; one person with commits in both is counted once. .EXAMPLE pwsh ./count-developers.ps1 -Path C:\src\my-app -Include intents, shared/prompts Two folders outside the project folders count as IntentSharp code too. .OUTPUTS Text on the console. Exit code 0 when the count was made (0 is a valid count), 1 on a usage error: a path that is not a git work tree or not the root of its repository, an -Include folder that no repository has, a period whose end is before its start, or git not found. #> # Below the help on purpose: with #Requires on the first line, PowerShell 7 shows no help for the script. #Requires -Version 7.2 [CmdletBinding()] param( [string[]] $Path = @((Get-Location).ProviderPath), [datetime] $Since = (Get-Date).Date.AddMonths(-12), [datetime] $Until = (Get-Date).Date, [string[]] $Include = @(), [switch] $Quiet ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest $invariant = [Globalization.CultureInfo]::InvariantCulture $packages = 'IntentSharp', 'IntentSharp.Catalog', 'IntentSharp.Extensions.AI' # A PackageReference element whose Include names one of the packages, alone or as one item of a semicolon-separated # list (legal MSBuild: Include="IntentSharp;IntentSharp.Catalog"), in any attribute order, in single or double quotes, # with or without a Version attribute (Central Package Management puts the version in Directory.Packages.props), and # across line breaks inside the element. A package id that only starts with one of the names (IntentSharp.Other) is # not a match. The pattern runs on the file with its XML comments removed, so a commented-out reference does not count. $packageIds = ($packages | ForEach-Object { [regex]::Escape($_) }) -join '|' $referencePattern = [regex]::new( ']*?\bInclude\s*=\s*["'']\s*(?:[^"'';]*;\s*)*(?:' + $packageIds + ')\s*(?:;[^"'']*)?["'']', [Text.RegularExpressions.RegexOptions]::IgnoreCase) $xmlCommentPattern = [regex]::new('', [Text.RegularExpressions.RegexOptions]::Singleline) # The project files among the tracked files. git gets no pattern: on macOS and Linux PowerShell expands a pattern # given to a native command against the current directory when a file there matches it, so the filter is done here. $projectFilePattern = [regex]::new('\.(?:csproj|fsproj|vbproj|props|targets)$', [Text.RegularExpressions.RegexOptions]::IgnoreCase) function Stop-WithUsageError([string] $message) { Write-Error -Message $message -ErrorAction Continue exit 1 } function Invoke-Git([string] $root, [string[]] $arguments) { # Runs git in $root and returns its output lines. A failure is reported with git's own message. $output = & git -C $root @arguments 2>&1 if ($LASTEXITCODE -ne 0) { $text = ($output | ForEach-Object { "$_" }) -join ' ' throw "git $($arguments[0]) failed in ${root}: $text" } return @($output | ForEach-Object { "$_" }) } function Test-FolderInHistory([string] $root, [string] $folder) { # True when a commit of the checked-out branch touched the folder: a project that was moved, renamed or removed # is no longer on disk and is still in the history. False in a repository with no commit yet. $found = & git -C $root log -1 '--format=%H' -- $folder 2>&1 return ($LASTEXITCODE -eq 0) -and ("$found" -ne '') } if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Stop-WithUsageError 'git was not found. Install git, or add it to PATH, and run the script again.' } if ($Until.Date -lt $Since.Date) { Stop-WithUsageError "-Until ($($Until.ToString('yyyy-MM-dd', $invariant))) is before -Since ($($Since.ToString('yyyy-MM-dd', $invariant)))." } # git prints file names and author names in UTF-8; decode them as such whatever the console's code page. $previousEncoding = [Console]::OutputEncoding [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) try { # Each repository: its root as given, resolved, which must be the top of its work tree. A folder below the top # would scope the tracked files and the log to that folder and give a number for part of the repository. $repositories = foreach ($p in $Path) { if (-not (Test-Path -LiteralPath $p -PathType Container)) { Stop-WithUsageError "$p is not a folder." } $full = (Resolve-Path -LiteralPath $p).ProviderPath $inside = & git -C $full rev-parse --is-inside-work-tree 2>&1 if ($LASTEXITCODE -ne 0 -or "$inside" -ne 'true') { Stop-WithUsageError "$full is not a git work tree. Pass the folder of a repository (the one that holds .git) with -Path." } # The folder's path from the top of the work tree: empty at the top itself. $prefix = & git -C $full rev-parse --show-prefix 2>&1 if ($LASTEXITCODE -ne 0) { throw "git rev-parse failed in ${full}: $prefix" } if ("$prefix" -ne '') { $top = [IO.Path]::GetFullPath("$(& git -C $full rev-parse --show-toplevel 2>&1)") Stop-WithUsageError "$full is inside the repository at $top but is not its root; pass $top with -Path." } [pscustomobject]@{ Root = $full } } # The -Include folders, placed in the repositories that have them: on disk, or in the history for a folder that # was moved, renamed or removed. A folder outside a repository (a sibling reached with ..) is not in it. $includesByRoot = @{} foreach ($repo in $repositories) { $includesByRoot[$repo.Root] = [Collections.Generic.List[string]]::new() } foreach ($folder in $Include) { $placed = $false foreach ($repo in $repositories) { $candidate = if ([IO.Path]::IsPathRooted($folder)) { [IO.Path]::GetFullPath($folder) } else { [IO.Path]::GetFullPath((Join-Path $repo.Root $folder)) } $rel = [IO.Path]::GetRelativePath($repo.Root, $candidate) if ($rel -ne '.' -and ($rel -eq '..' -or $rel.StartsWith('..' + [IO.Path]::DirectorySeparatorChar) -or [IO.Path]::IsPathRooted($rel))) { continue } $rel = $rel.Replace('\', '/') if ((Test-Path -LiteralPath $candidate -PathType Container) -or (Test-FolderInHistory $repo.Root $rel)) { $includesByRoot[$repo.Root].Add($rel) $placed = $true } } if (-not $placed) { Stop-WithUsageError "-Include folder $folder is in none of the repositories given with -Path." } } $sinceDay = $Since.ToString('yyyy-MM-dd', $invariant) $untilDay = $Until.ToString('yyyy-MM-dd', $invariant) $people = @{} # lower-case email -> person $referencing = 0 # project files that reference the packages, over every repository foreach ($repo in $repositories) { $root = $repo.Root # The tracked files, NUL-separated so that any file name survives, filtered here to the project files. $listed = & git -C $root ls-files -z 2>&1 if ($LASTEXITCODE -ne 0) { throw "git ls-files failed in ${root}: $listed" } $projectFiles = @(($listed | ForEach-Object { "$_" }) -join '' -split "`0" | Where-Object { $_ -ne '' -and $projectFilePattern.IsMatch($_) }) $scope = [Collections.Generic.SortedSet[string]]::new([StringComparer]::Ordinal) foreach ($file in $projectFiles) { $fullFile = Join-Path $root $file if (-not (Test-Path -LiteralPath $fullFile -PathType Leaf)) { continue } # tracked but deleted in the work tree $text = $xmlCommentPattern.Replace([IO.File]::ReadAllText($fullFile), '') if (-not $referencePattern.IsMatch($text)) { continue } $referencing++ $folder = [IO.Path]::GetDirectoryName($file).Replace('\', '/') [void] $scope.Add($(if ($folder -eq '') { '.' } else { $folder })) } foreach ($folder in $includesByRoot[$root]) { [void] $scope.Add($(if ($folder -eq '') { '.' } else { $folder })) } if ($scope.Count -eq 0) { continue } # A repository with no commit yet (files added, nothing committed) has nobody to count. & git -C $root rev-parse --verify --quiet HEAD *> $null if ($LASTEXITCODE -ne 0) { continue } # One line per commit: hash, author name, author email, author date (ISO 8601), separated by the unit separator. # %aN and %aE apply the repository's .mailmap. Merge commits are left out: the merged commits carry the authors. # --full-history keeps every commit that changed the scope: without it git drops a branch whose changes were # undone or deleted before the merge, and with it the people who wrote them. $lines = Invoke-Git $root (@('log', '--full-history', '--no-merges', '--format=%H%x1f%aN%x1f%aE%x1f%aI', '--') + @($scope)) foreach ($line in $lines) { if ($line -eq '') { continue } $parts = $line.Split([char] 0x1f) if ($parts.Length -ne 4) { continue } $day = $parts[3].Substring(0, 10) # the author's calendar day, as git recorded it if ([string]::CompareOrdinal($day, $sinceDay) -lt 0 -or [string]::CompareOrdinal($day, $untilDay) -gt 0) { continue } $key = $parts[2].ToLowerInvariant() if (-not $people.ContainsKey($key)) { $people[$key] = [pscustomobject]@{ Name = $parts[1]; Email = $parts[2]; First = $day; Last = $day; Commits = 0; LastSeen = $parts[3] } } $person = $people[$key] $person.Commits++ if ([string]::CompareOrdinal($day, $person.First) -lt 0) { $person.First = $day } if ([string]::CompareOrdinal($parts[3], $person.LastSeen) -gt 0) { # The name and the spelling of the email from the most recent commit. $person.LastSeen = $parts[3]; $person.Last = $day; $person.Name = $parts[1]; $person.Email = $parts[2] } } } $count = $people.Count if ($Quiet) { Write-Output $count exit 0 } if ($referencing -eq 0 -and ($Include.Count -eq 0)) { Write-Output "No project in the repositories given references the IntentSharp packages (a PackageReference to $($packages -join ', ') in a tracked project, .props or .targets file)." } elseif ($count -gt 0) { $rows = @($people.Values | Sort-Object -Property Last, First, Email) $nameWidth = [int] [Math]::Max(4, (@($rows | ForEach-Object { $_.Name.Length }) | Measure-Object -Maximum).Maximum) $emailWidth = [int] [Math]::Max(5, (@($rows | ForEach-Object { $_.Email.Length }) | Measure-Object -Maximum).Maximum) Write-Output ('{0} {1} {2} {3} {4}' -f 'Name'.PadRight($nameWidth), 'Email'.PadRight($emailWidth), 'First'.PadRight(10), 'Last'.PadRight(10), 'Commits') Write-Output ('{0} {1} {2} {3} {4}' -f ('-' * $nameWidth), ('-' * $emailWidth), ('-' * 10), ('-' * 10), ('-' * 7)) foreach ($row in $rows) { Write-Output ('{0} {1} {2} {3} {4}' -f $row.Name.PadRight($nameWidth), $row.Email.PadRight($emailWidth), $row.First, $row.Last, $row.Commits.ToString($invariant).PadLeft(7)) } # Two people whose periods do not overlap (one's last commit before the other's first) may be one seat that # passed from one person to the next. The number printed is still the distinct count; the docs page explains. $sequential = $false for ($i = 0; $i -lt $rows.Count -and -not $sequential; $i++) { for ($j = 0; $j -lt $rows.Count; $j++) { if ($i -ne $j -and [string]::CompareOrdinal($rows[$i].Last, $rows[$j].First) -lt 0) { $sequential = $true; break } } } if ($sequential) { Write-Output '' Write-Output 'Sequential ranges can be one reassigned seat; see the docs page.' } } else { Write-Output "No commit changed IntentSharp code between $sinceDay and $untilDay." } Write-Output '' Write-Output "Developers who changed IntentSharp code between $sinceDay and ${untilDay}: $count" exit 0 } finally { [Console]::OutputEncoding = $previousEncoding }