Save-GitStash.ps1

function Save-GitStash {
    <#
    .SYNOPSIS
        Save working-tree and index changes in a new git stash.
    .DESCRIPTION
        Runs git stash push using literal arguments. By default only tracked
        changes are saved and reset to HEAD; untracked and ignored files remain.
        Uses git's native index, submodule and nested-repository behavior without
        additional cleanup or recursion. Never applies, pops or removes a stash.
 
        Emits a GitStash only when refs/stash changes after a successful push.
        Nothing to save, -WhatIf and declined confirmation produce no result.
        Git informational output is available with -Verbose; successful git
        warnings are forwarded to the warning stream. Git failures are errors.
        Does not change the caller's location or LASTEXITCODE.
 
        Avoid concurrent stash operations in this repository (including linked
        worktrees) while saving: git locks its updates, but the before/push/after
        identity reads are not an atomic transaction with other git processes.
    .PARAMETER Path
        Literal directory inside a working tree. Defaults to the current location.
        The entire working tree is targeted, even from a subdirectory.
        Accepts pipeline paths and objects with Path, RepositoryPath or RepoPath.
        Bare repositories are not supported.
    .PARAMETER KeepIndex
        Leave staged changes in the index and working tree after saving. The
        stash still contains both staged and unstaged tracked changes.
    .PARAMETER IncludeUntracked
        Also save and remove untracked files, but leave ignored files alone.
        Cannot be combined with -All.
    .PARAMETER All
        Also save and remove untracked and ignored files, subject to git's
        native nested-repository protections. Cannot be combined with
        -IncludeUntracked.
    .PARAMETER Message
        Optional message passed as one literal argument, without trimming or
        shell evaluation. Null, empty or whitespace-only values use git's default
        message. Git controls the stored message and subject formatting.
    .OUTPUTS
        GitStash
        ObjectId is the full stash commit ID (not a moving stash@{N} selector).
        RepositoryPath is the resolved input directory, not necessarily the
        repository root. Subject is git's contents:subject for the stash commit.
        Retain ObjectId and RepositoryPath together to identify the saved stash
        even after later pushes renumber the stack. This object does not pin the
        commit against future dropping, clearing or garbage collection.
    .EXAMPLE
        Save-GitStash -Message 'Pause work'
        Saves tracked changes in the current repository.
    .EXAMPLE
        Save-GitStash -Path ../project -KeepIndex -IncludeUntracked
        Saves tracked and untracked changes, keeping staged changes checked out.
    .EXAMPLE
        Get-Worktrees | Save-GitStash -All -WhatIf
        Previews saving all eligible changes in each supplied worktree.
    #>

    [OutputType('GitStash')]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('RepositoryPath', 'RepoPath')]
        [string]$Path,

        [switch]$KeepIndex,

        [switch]$IncludeUntracked,

        [switch]$All,

        [AllowNull()]
        [AllowEmptyString()]
        [ValidateScript({ -not $_.Contains([char]0) }, ErrorMessage = 'A stash message must not contain NUL characters.')]
        [string]$Message
    )

    begin {
        if ($All -and $IncludeUntracked) {
            $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new(
                [System.ArgumentException]::new('Use either -All or -IncludeUntracked, not both. -All already includes untracked files.'),
                'GitStashOptionsConflict', [System.Management.Automation.ErrorCategory]::InvalidArgument, $null))
        }

        $arguments = @('stash', 'push')
        if ($KeepIndex) { $arguments += '--keep-index' }
        if ($IncludeUntracked) { $arguments += '--include-untracked' }
        if ($All) { $arguments += '--all' }
        if (-not [string]::IsNullOrWhiteSpace($Message)) { $arguments += @('-m', $Message) }
    }

    process {
        # for-each-ref succeeds with empty output when no stash exists.
        $before = Invoke-Git -Path $Path -Arguments @('for-each-ref', '--format=%(objectname)', '--', 'refs/stash')
        if ($null -eq $before) { return }
        $repositoryPath = $before.RepositoryPath
        $scope = if ($All) { 'tracked, untracked and ignored' }
            elseif ($IncludeUntracked) { 'tracked and untracked' }
            else { 'tracked' }
        $action = "Save $scope changes in a git stash"
        if ($KeepIndex) { $action += ', keeping staged changes' }
        if (-not $PSCmdlet.ShouldProcess($repositoryPath, $action)) { return }

        $push = Invoke-Git -Path $repositoryPath -Arguments $arguments
        if ($null -eq $push) { return }
        if ($push.StandardOutput) { Write-Verbose $push.StandardOutput.TrimEnd() }
        if ($push.StandardError) { Write-Warning $push.StandardError.TrimEnd() }

        $after = Invoke-Git -Path $repositoryPath -Arguments @(
            'for-each-ref', '--format=%(objectname)%00%(contents:subject)', '--', 'refs/stash'
        )
        if ($null -eq $after) { return }
        if (-not $after.StandardOutput) { return }
        $record = $after.StandardOutput.TrimEnd("`r", "`n") -split "`0", 2
        if ($record.Count -ne 2 -or $record[0] -cnotmatch '^(?:[0-9a-f]{40}|[0-9a-f]{64})$') {
            $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new(
                [System.FormatException]::new('Unexpected git stash reference output; the saved stash identity could not be read.'),
                'GitStashIdentityInvalid', [System.Management.Automation.ErrorCategory]::InvalidData, $repositoryPath))
        }
        if ($record[0] -ceq $before.StandardOutput.Trim()) { return }

        [PSCustomObject]@{
            PSTypeName     = 'GitStash'
            ObjectId       = $record[0]
            RepositoryPath = $repositoryPath
            Subject        = $record[1]
        }
    }
}