Public/Select-GitCommit.ps1

using namespace System.Collections.Generic
using namespace System.Management.Automation

function Select-GitCommit
{
    <#
        .SYNOPSIS
        Performs a rebase.

        .DESCRIPTION
        You can rebase one branch onto another, or you can rebase a single branch to rewrite
        history.

        A git rebase takes commits, starting from a particular commit, and replays them with some
        change. Broadly-speaking, there are two use cases:

        - rebasing onto another branch, e.g. staging, so that your feature branch is up-to-date with
        upstream changes
        - rebasing interactively, so that you can clean up your commit history

        They are not strictly exclusive.

        This command runs git rebase with the --autostash parameter; any uncommitted changes will
        be stashed at the start of the rebase and re-applied at the end.

        This command also uses --autosquash option; any commits with messages beginning with
        'fixup!' or 'squash!' will be moved to the matching commit in the history and squashed into
        that commit. This is skipped if no matching commit is found.

        .PARAMETER Count
        Specify the number of commits to include in the rebase.

        .PARAMETER Onto
        Specify a branch or commit reference onto which to rebase.

        .PARAMETER FromRef
        Specify the starting commit for the rebase. This is exclusive - the commit provided will not
        be touched. Only the commits after the specified commit will be included in the rebase.

        .PARAMETER Interactive
        Specifies to perform an interactive rebase. This command may return before the rebase is
        complete; the user may need to run git commands to complete the rebase. Help will be shown
        in the terminal.

        .PARAMETER NoAutosquash
        Specifies that an interactive rebase should not automatically perform squashes and fixups.

        .PARAMETER Abort
        Aborts an interactive rebase that has paused at an intermediate point.

        .PARAMETER Continue
        Continues an interactive rebase that has paused at an intermediate point.

        .PARAMETER Skip
        Skips the current commit in an interactive rebase that has paused at an intermediate point.

        .OUTPUTS
        [psobject]

        .EXAMPLE
        rebase staging

        rebase: moved from 3ed185d to 0eb7f36
         CHANGELOG.md | 4 +
         GitShell.psd1 | 2 +-
         Jenkinsfile (gone) | 749 -----------------------------------------------
         Public/New-GitBranch.ps1 | 5 +
         Public/New-GitRepo.ps1 | 6 +-
         docs/README.md | 4 +-
         6 files changed, 17 insertions(+), 753 deletions(-)

        Rebases commits since the last merge onto the staging branch. Outputs a summary of the diff
        between the old and new heads.

        If this rebase does not complete successfully, it will abort and return to the original
        state.

        .EXAMPLE
        rebase staging -Interactive

        Created autostash: 72c6252
        error: could not apply 5d99581... Version bump
        Resolve all conflicts manually, mark them as resolved with
        "git add/rm <conflicted_files>", then run "git rebase --continue".
        You can instead skip this commit: run "git rebase --skip".
        To abort and get back to the state before "git rebase", run "git rebase --abort".
        Could not apply 5d99581... Version bump
        Auto-merging GitShell.psd1
        CONFLICT (content): Merge conflict in GitShell.psd1
        Auto-merging CHANGELOG.md
        CONFLICT (content): Merge conflict in CHANGELOG.md

        # Edit GitShell.psd1 and CHANGELOG.md to resolve the conflicts
        > git add *
        > rebase -Continue

        [detached HEAD dd33e45] Version bump
         2 files changed, 27 insertions(+), 1 deletion(-)
        rebase: moved from 5d99581 to dd33e45
         CHANGELOG.md | 4 +
         GitShell.psd1 | 4 -
         Jenkinsfile (gone) | 749 -----------------------------------------------
         Public/New-GitBranch.ps1 | 5 +
         Public/New-GitRepo.ps1 | 6 +-
         docs/README.md | 4 +-
         6 files changed, 16 insertions(+), 756 deletions(-)
        Applied autostash.
        Successfully rebased and updated refs/heads/4165-rebasing.

        Opens the Gitlens extension in VS Code to interactively order and amend the git history,
        before rebasing onto staging.

        In this example, a merge conflict was introduced in the GitShell.psd1 and CHANGELOG.md
        files. The command dropped to the terminal. We had to manually edit those files to resolve
        the conflicts. Then we ran `git add *` and `git rebase --continue` to complete the rebase.

        On success, outputs a summary of the diff between the old and new heads.

        .EXAMPLE
        > ggl 8

        Id Author UpdatedAt Summary
        -- ------ --------- -------
        3d42d52 Freddie Sackur 6 minutes ago fixup! Summary after successful interactive rebase
        aa505ef Freddie Sackur 7 minutes ago fixup! Don't output to Info stream on calls within the module
        8784987 Freddie Sackur 11 minutes ago Updated help
        ba8fb23 Freddie Sackur 12 minutes ago Summary after successful interactive rebase
        45c7c99 Freddie Sackur 2 hours ago Add Undo-Git
        973e46c Freddie Sackur 3 hours ago Added deprecation warning to Untracked
        1d70004 Freddie Sackur 3 hours ago Tidy
        927400e Freddie Sackur 5 hours ago Don't output to Info stream on calls within the module

        > rebase 8

        rebase: moved from 3d42d52 to 3680238
        Successfully rebased and updated refs/heads/4165-rebasing.

        > ggl 6

        Id Author UpdatedAt Summary
        -- ------ --------- -------
        3680238 Freddie Sackur 11 minutes ago Updated help
        273dc05 Freddie Sackur 12 minutes ago Summary after successful interactive rebase
        b7f1385 Freddie Sackur 2 hours ago Add Undo-Git
        238f6b6 Freddie Sackur 3 hours ago Added deprecation warning to Untracked
        55e185e Freddie Sackur 3 hours ago Tidy
        f79de78 Freddie Sackur 5 hours ago Don't output to Info stream on calls within the module

        Performs a non-interactive autosquash rebase. Before the rebase, there are two commits with
        a 'fixup!' prefix, corresponding to two older commits in the history. After the rebase,
        these fixup commits have been squashed into the older commits.
    #>


    [CmdletBinding(DefaultParameterSetName = 'Count')]
    param ()

    dynamicparam
    {
        $DynParams = [RuntimeDefinedParameterDictionary]::new()

        if (-not $Script:RebaseParams)
        {
            function _rebase
            {
                param
                (
                    #region params for active rebase
                    [Parameter(ParameterSetName = 'Continue', Mandatory)]
                    [switch]$Continue,

                    [Parameter(ParameterSetName = 'Abort', Mandatory)]
                    [switch]$Abort,

                    [Parameter(ParameterSetName = 'Skip', Mandatory)]
                    [switch]$Skip,
                    #endregion params for active rebase

                    #region params for starting a rebase
                    [Parameter(ParameterSetName = 'Count', Position = 0)]
                    [ValidateRange(1, 5000)]
                    [int]$Count,

                    [Parameter(ParameterSetName = 'FromRef', Position = 0)]
                    [Parameter(ParameterSetName = 'Count')]
                    [string]$Onto,

                    [Parameter(ParameterSetName = 'FromRef')]
                    [string]$FromRef,

                    [Parameter()]
                    [switch]$Interactive,

                    [Parameter()]
                    [switch]$NoAutosquash
                    #endregion params for starting a rebase
                )
            }

            $Script:RebaseParams = (Get-Command _rebase).Parameters.Values | Write-Output
        }


        $Params = if (Test-ActiveRebase) {$Script:RebaseParams[0..2]} else {$Script:RebaseParams[3..7]}
        foreach ($Param in $Params)
        {
            $DynParam = [RuntimeDefinedParameter]::new(
                $Param.Name,
                $Param.ParameterType,
                $Param.Attributes
            )
            $DynParams.Add($DynParam.Name, $DynParam)
        }

        return $DynParams
    }

    end
    {
        $PSBoundParameters.GetEnumerator() | ForEach-Object {Set-Variable $_.Key $_.Value}

        if ($PSCmdlet.ParameterSetName -in ('Abort', 'Continue', 'Skip'))
        {
            try
            {
                $_editor = $env:GIT_EDITOR
                $env:GIT_EDITOR = 'true'        # short-circuits git's editor, so --continue doesn't prompt
                return git rebase --$($PSCmdlet.ParameterSetName.ToLower())
            }
            finally
            {
                $env:GIT_EDITOR = $_editor
            }
        }


        $RebaseArgs = @(
            "rebase",
            "-i",               # Always operate in interactive mode, to apply autosquash - we hack it with GIT_SEQUENCE_EDITOR
            "--autostash"
        )

        if (-not ($Interactive -and $NoAutosquash))
        {
            $RebaseArgs += "--autosquash"
        }

        if ($Onto)
        {
            $RebaseArgs += "--onto"
            $RebaseArgs += $Onto
        }

        if (-not $FromRef)
        {
            if ($Count)
            {
                $FromRef = "HEAD~$Count"
            }
            else
            {
                $FromRef = Get-GitLog -SinceLastMerge | Select-Object -Last 1 -ExpandProperty Id
                if (-not $FromRef)
                {
                    throw "Unable to determine the last merge in the commit history to use as a rebase base. Try specifying FromRef or Count."
                }
                $FromRef = "$FromRef^1"
            }
        }
        $RebaseArgs += $FromRef


        if ($Interactive)
        {
            return git $RebaseArgs
        }


        try
        {
            $Output = $null

            $_gse = $env:GIT_SEQUENCE_EDITOR
            $env:GIT_SEQUENCE_EDITOR = "true"   # short-circuits git's todo editor, but not the primary editor

            $Output = git $RebaseArgs 2>&1

            if ($LASTEXITCODE)
            {
                throw $LASTEXITCODE
            }
        }

        catch
        {
            $Escape    = [char]27
            $Firebrick = "$Escape[38;2;178;34;34m"
            $Reset     = "$Escape[0m"
            @($Output) -match '^CONFLICT' | ForEach-Object {Write-Information "$Firebrick$_$Reset" -InformationAction Continue}
            $Output | Select-Object -Last 1 | Write-Error

            git rebase --abort
        }

        finally
        {
            $env:GIT_SEQUENCE_EDITOR = $_gse
        }
    }
}


Register-ArgumentCompleter -CommandName Select-GitCommit -ParameterName Onto -ScriptBlock {
    param ($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

    $BranchNames = Get-GitBranch | Where-Object Active -ne $true | Select-Object -ExpandProperty Name

    ($BranchNames -like "$wordToComplete*"), ($BranchNames -like "*?$wordToComplete*") | Write-Output
}