posh-git-workflow.psm1
# TODO: delete branch [string]$global:GitWorkflow_PullRequestUrlConfigKey = 'workflow.pullrequesturl'; function Sync-Fork { <# .SYNOPSIS Syncs fork with upstream (original) repository. .DESCRIPTION Fetches changes from upstream, rebases master onto upstream/master, pushes master to origin and prunes stale remote tracking refs. #> [CmdletBinding(SupportsShouldProcess=$false)] Param() trap { return; } SyncFork } function Get-Features { <# .SYNOPSIS Returns feature branches. .LINK Get-Releases .LINK Get-ReleasesFixes #> [CmdletBinding()] param () trap { return; } GetFeatureBranches | Select Name,ShortRefName,RefName,IsHead; } function Get-Releases { <# .SYNOPSIS Returns release branches. .LINK Get-ReleasesFixes .LINK Get-Features #> [CmdletBinding()] param () trap { return; } GetReleaseBranches | Select Name,ShortRefName,RefName,IsHead; } function Get-ReleaseFixes { <# .SYNOPSIS Returns release fix branches. .LINK Get-Releases .LINK Get-Features #> [CmdletBinding()] param () trap { return; } GetReleaseFixBranches | Select Name,ShortRefName,RefName,IsHead; } function Get-Hotfixes { [CmdletBinding()] param () trap { return; } GethotfixBranches | Select Name,ShortRefName,RefName,IsHead; } function New-Feature { <# .SYNOPSIS Creates new feature branch. .DESCRIPTION Syncs fork and creates branch with the specified name prefixed with 'feature/' on latest master. .PARAMETER Name Branch name without 'feature/' prefix. .EXAMPLE New-Feature cool-stuff .LINK Sync-Fork .LINK New-Release .LINK New-ReleaseFix #> [CmdletBinding(SupportsShouldProcess=$false)] param( [Parameter(Mandatory=$true, Position=0, ParameterSetName="Name", HelpMessage="Branch name")] [ValidateNotNullOrEmpty()] [string] $Name ) trap { return; } SyncFork ExecuteGitCommand 'git checkout' "-B feature/$Name" '--progress' } function New-ReleaseFix { <# .SYNOPSIS Creates new release fix branch. .DESCRIPTION Syncs fork and creates branch with the specified name on top of selected release branch. New branch name is constructed in the following manner: release/release-name/branch-name. Release name is optional if there's only one release branch. .PARAMETER Name Branch name without release branch prefix .PARAMETER ReleaseName Release branch name without release prefix .EXAMPLE New-ReleaseFix bug-fix .LINK Sync-Fork .LINK New-Feature .LINK New-Release #> [CmdletBinding(SupportsShouldProcess=$false)] param( [Parameter(Mandatory=$true, Position=0, HelpMessage="Branch name")] [ValidateNotNullOrEmpty()] [string] $Name, [Parameter(Mandatory=$false, Position=1, HelpMessage="Release branch name")] [string] $ReleaseName ) SyncFork $releaseBranches = @(GetReleaseBranches); if ($releaseBranches.Count -eq 0) { Write-Error 'No release branches found.' return; } $releaseBranch = $null; if ($ReleaseName -eq '') { $currentBranch = $releaseBranches | Where {$_.IsHead -eq $True} | Select -First 1; if ($currentBranch -ne $null) { $releaseBranch = $currentBranch; } if ($releaseBranch -eq $null -and $releaseBranches.Count -eq 1) { $releaseBranch = $releaseBranches[0]; } if ($releaseBranch -eq $null) { # TODO: Show prompt #$choices = @( # New-Object "System.Management.Automation.Host.ChoiceDescription" "&Apple", "Apple" # New-Object "System.Management.Automation.Host.ChoiceDescription" "&Banana", "Banana" # New-Object "System.Management.Automation.Host.ChoiceDescription" "&Orange", "Orange" #) # ## Single-choice prompt #$host.UI.PromptForChoice("Choose a fruit", "You may choose one", $choices, 1) Write-Error 'Please specify release branch name.' return; } } else { $releaseBranch = $releaseBranches | Where {$_.Name -eq $ReleaseName} | Select -First 1; if ($releaseBranch -eq $null) { Write-Error "Release branch $ReleaseName not found." return; } } $releaseRefName = $releaseBranch.ShortRefName; $ReleaseName = $releaseBranch.Name; ExecuteGitCommand 'git checkout' "-b release/$ReleaseName/$Name --no-track $releaseRefName" '--progress' } Register-ArgumentCompleter -CommandName New-ReleaseFix -ParameterName ReleaseName -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter) GetReleaseBranches | Where {NameStartsWith $_ $wordToComplete} | ForEach-Object { ToBranchCompletionResult $_} } function Push-Feature { <# .SYNOPSIS Pushes feature branch to origin. .DESCRIPTION Pushes specified feature branch to origin. If no branch name specified the current feature branch will be pushed. .PARAMETER Name Branch name without feature prefix. .EXAMPLE Push-Feature cool-stuff .LINK Push-ReleaseFix #> [CmdletBinding(SupportsShouldProcess=$false)] param( [Parameter(Mandatory=$false, Position=0, ParameterSetName="Name", HelpMessage="Branch name")] [ValidateNotNullOrEmpty()] [string] $Name ) trap { return; } $branch = GetFeatureBranches | Where {$_.IsLocal -and (HasNameOrCurrent $_ $Name)}; PushBranch $branch; } Register-ArgumentCompleter -CommandName Push-Feature -ParameterName Name -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter) GetFeatureBranches | Where {$_.IsLocal -and (NameStartsWith $_ $wordToComplete)} | ForEach-Object { ToBranchCompletionResult $_} } function Push-ReleaseFix { <# .SYNOPSIS Pushes release fix branch to origin. .DESCRIPTION Pushes specified release fix branch to origin. If no branch name specified the current release fix branch will be pushed. .PARAMETER Name Branch name without release branch prefix. .EXAMPLE Push-ReleaseFix bug-fix .LINK Push-Feature #> [CmdletBinding(SupportsShouldProcess=$false)] param( [Parameter(Mandatory=$false, Position=0, ParameterSetName="Name", HelpMessage="Branch name")] [ValidateNotNullOrEmpty()] [string] $Name ) trap { return; } $branch = GetReleaseFixBranches | Where {$_.IsLocal -and (HasNameOrCurrent $_ $Name)}; PushBranch $branch; } Register-ArgumentCompleter -CommandName Push-ReleaseFix -ParameterName Name -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter) GetReleaseFixBranches | Where {$_.IsLocal -and (NameStartsWith $_ $wordToComplete)} | ForEach-Object { ToBranchCompletionResult $_} } function Complete-Feature { <# .SYNOPSIS Pushes feature branch and submits pull request. .DESCRIPTION Pushes feature branch to origin and submits pull request (if configured). If no branch name specified the current feature branch will be used. .PARAMETER Name Branch name without feature prefix. .EXAMPLE Complete-Feature cool-stuff .LINK Set-PullRequestUrl .LINK Complete-ReleaseFix #> [CmdletBinding(SupportsShouldProcess=$false)] param( [Parameter(Mandatory=$false, Position=0, ParameterSetName="Name", HelpMessage="Branch name")] [ValidateNotNullOrEmpty()] [string] $Name ) trap { "Error: $_"; return; } $branch = GetFeatureBranches | Where {HasNameOrCurrent $_ $Name}; PushBranch $branch; SubmitPullRequest $branch; } Register-ArgumentCompleter -CommandName Complete-Feature -ParameterName Name -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter) GetFeatureBranches | Where {NameStartsWith $_ $wordToComplete} | ForEach-Object { ToBranchCompletionResult $_} } function Complete-ReleaseFix { <# .SYNOPSIS Pushes release fix branch and submits pull request. .DESCRIPTION Pushes release fix branch to origin and submits pull request (if configured). If no branch name specified the current release fix branch will be used. .PARAMETER Name Branch name without release branch prefix. .EXAMPLE Complete-ReleaseFix bug-fix .LINK Set-PullRequestUrl .LINK Complete-Feature #> [CmdletBinding(SupportsShouldProcess=$false)] param( [Parameter(Mandatory=$false, Position=0, ParameterSetName="Name", HelpMessage="Branch name")] [ValidateNotNullOrEmpty()] [string] $Name ) trap { "Error: $_"; return; } $branch = GetReleaseFixBranches | Where {HasNameOrCurrent $_ $Name}; PushBranch $branch; SubmitPullRequest $branch; } Register-ArgumentCompleter -CommandName Complete-ReleaseFix -ParameterName Name -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter) GetReleaseFixBranches | Where {NameStartsWith $_ $wordToComplete} | ForEach-Object { ToBranchCompletionResult $_} } function Complete-Release { <# .SYNOPSIS Merges release branch to master. .DESCRIPTION Merges release branch to master, creates tag with merged branch name and pushes changes to upstream. .PARAMETER Name Branch name without release prefix. .PARAMETER Message Release tag message. .EXAMPLE Complete-Release v1.1 -Message 'Awesome release' .LINK Complete-ReleaseFix #> [CmdletBinding(SupportsShouldProcess=$false)] param( [Parameter(Mandatory=$false, Position=0, HelpMessage="Branch name")] [ValidateNotNullOrEmpty()] [string] $Name, [Parameter(Mandatory=$false, Position=0, HelpMessage="Tag message")] [ValidateNotNullOrEmpty()] [string] $Message ) trap { return; } # check is dirty Sync-Fork $branch = GetReleaseBranches | Where {$_.IsLocal -and (HasNameOrCurrent $_ $Name)}; if ($branch -eq $null) { Write-Error "Branch 'release/$Name' not found"; return; } $Name = $branch.Name; $commitId = ExecuteGitCommand 'git rev-parse' $branch.ShortRefName; ExecuteGitCommand 'git merge' "--no-ff -m `"Merge 'release/$Name'`" $commitId"; ExecuteGitCommand 'git push' '--set-upstream upstream/master'; ExecuteGitCommand 'git branch' '--set-upstream-to origin/master' ExecuteGitCommand 'git push' 'upstream --delete release/$Name' $messageParameter = ''; if (-not [System.String]::IsNullOrWhitespace($Message)) { $messageParameter = "--message='$Message'"; } ExecuteGitCommand 'git tag' "$messageParameter release/$Name $commitId" ExecuteGitCommand 'git push' "upstream --tags" } Register-ArgumentCompleter -CommandName Complete-Release -ParameterName Name -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter) GetReleaseBranches | Where {NameStartsWith $_ $wordToComplete} | ForEach-Object { ToBranchCompletionResult $_} } function Set-PullRequestUrl { <# .SYNOPSIS Sets submit pull request page URL in local git config. .DESCRIPTION Creates or updates local git config section 'workflow' and sets 'pullrequesturl' key value to the specified URL. .PARAMETER Url Submit pull request page URL. The URL can be in a form of a template with this placeholders: {0} - sourceRef {1} - targetRef GitHub URL example: https://github.com/<repo-owner-id>/<original-repo-id>/compare/{1}...<my-github-user-id>:{0} Visual Studio Online URL example: https://<my-account-id>.visualstudio.com/_git/<my-repo-id>/pullrequestcreate?sourceRef={0}&targetRef={1}&sourceRepositoryId=<my-fork-GUID>&targetRepositoryId=<main-repo-GUID> .EXAMPLE Set-PullRequestUrl 'https://github.com/octocat/Spoon-Knife/compare/master...my-github-user-id:master' .EXAMPLE Set-PullRequestUrl 'https://my-account.visualstudio.com/_git/my-repo/pullrequestcreate?sourceRef={0}&targetRef={1}&sourceRepositoryId=my-fork-repo-GUID&targetRepositoryId=main-repo-GUID' #> [CmdletBinding()] param ( [Parameter(Mandatory=$false, Position=0, ParameterSetName="Url", HelpMessage="Pull request URL")] [System.Uri] $Url ) trap { return; } SetGitConfigValue $GitWorkflow_PullRequestUrlConfigKey $Url; } function SubmitPullRequest { [CmdletBinding()] param ($branch) $pullRequestUrl = GetGitConfigValue $GitWorkflow_PullRequestUrlConfigKey; if ([System.String]::IsNullOrWhitespace($pullRequestUrl)) { return; } [System.Reflection.Assembly]::LoadWithPartialName('System.Web') | out-null; $sourceRef = $branch.ShortLocalRefName; $targetRef = 'master'; if ($branch.IsSubBranch -and -not $branch.IsOther) { $targetRef = $branch.ShortLocalParentRefName; } $sourceRef = [System.Web.HttpUtility]::UrlEncode($sourceRef); $targetRef = [System.Web.HttpUtility]::UrlEncode($targetRef); # "https://rebtel.visualstudio.com/_git/Backend/pullrequestcreate?sourceRef={0}&targetRef={1}&sourceRepositoryId=9b139ae4-3a6c-4946-9837-a14c15243bf3&targetRepositoryId=28e641a9-a204-46c4-b22a-303204901fe9" $pullRequestUrl = [System.String]::Format($pullRequestUrl, $sourceRef, $targetRef); (New-Object -Com Shell.Application).Open($pullRequestUrl); } function SyncFork { [CmdletBinding(SupportsShouldProcess=$false)] Param() $progress = 0; $progressStep = 100 / 8; if (HasMergeConflicts) { throw 'Merge conflicts detected.'; } $popStash = $false; if (IsDirty) { Write-Progress -Activity 'Syncing' -Status "Stashing changes..." -PercentComplete $progress ExecuteGitCommand 'git stash save --include-untracked'; $popStash = $true; } $progress += $progressStep; Write-Progress -Activity 'Syncing' -Status "Fetching latest changes from upstream..." -PercentComplete $progress ExecuteGitCommand 'git fetch --prune' 'upstream' '--progress --verbose' $progress += $progressStep; Write-Progress -Activity 'Syncing' -Status "Checkout master" -PercentComplete $progress ExecuteGitCommand 'git checkout' 'master' '--progress' $progress += $progressStep; Write-Progress -Activity 'Syncing' -Status "Rebasing master onto upstream/master" -PercentComplete $progress ExecuteGitCommand 'git merge --ff-only' 'upstream/master' '--verbose' $progress += $progressStep; Write-Progress -Activity 'Syncing' -Status "Pushing master" -PercentComplete $progress ExecuteGitCommand 'git push' 'origin master' '--progress --verbose' $progress += $progressStep; Write-Progress -Activity 'Syncing' -Status "Cleaning up stale origin branches" -PercentComplete $progress PruneRemoteBranches 'origin' $progress += $progressStep; Write-Progress -Activity 'Syncing' -Status "Cleaning up stale upstream branches" -PercentComplete $progress PruneRemoteBranches 'upstream' if ($popStash) { $progress += $progressStep; Write-Progress -Activity 'Syncing' -Status "Restoring stashed changes..." -PercentComplete $progress ExecuteGitCommand 'git stash pop'; } if (HasMergeConflicts) { throw 'Merge conflicts detected.'; } Write-Progress -Activity 'Syncing' -Completed } function PruneRemoteBranches { [CmdletBinding()] param ([string]$remote) $result = ExecuteGitCommand 'git remote prune' $remote; $staleBranches = [System.Collections.ArrayList]::new(); $prunedBranchPrefix = '* [pruned] '; foreach ($line in $result) { $line = $line.Trim(); if ($line.StartsWith($prunedBranchPrefix)) { # prune output looks like this: * [would prune] origin/feature/branch-name $branchName = $line.Substring($prunedBranchPrefix.Length + $remote.Length + 1); $staleBranches.Add($branchName); } } if ($staleBranches.Count -eq 0) { return; } $localBranchesToDelete = ''; GetAllBranches | Where {$_.IsLocal -and $staleBranches.Contains($_.ShortRefName)} | ForEach-Object {$localBranchesToDelete += $_.ShortRefName + ' '}; if ($localBranchesToDelete.Length -eq 0) { return; } ExecuteGitCommand 'git branch -D' $localBranchesToDelete '--verbose' } function PushBranch { [CmdletBinding(SupportsShouldProcess=$false)] param($branch) if ($branch -eq $null) { Write-Error "Branch $Name could not be found"; throw 'Branch does not exist.'; } ExecuteGitCommand 'git push --set-upstream origin' $branch.ShortRefName '--verbose --progress' } function IsDirty { $changes = git status --porcelain; return $changes -ne $null -and $changes.Length -ne 0; } function HasMergeConflicts { $changes = git status --porcelain; if ($changes -eq $null -or $changes.Length -eq 0) { return $false; } $conflictStatuses = 'DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'; return ($changes | Where {$conflictStatuses.Contains($_.Substring(0, 2))}).Length -gt 0; } function HasNameOrCurrent { param($branch, $name) if ([System.String]::IsNullOrEmpty($Name) -and $branch.IsHead) { return $branch } if ($branch.Name -eq $Name) { return $branch; } return $null; } function GetReleaseBranches () { GetAllBranches | Where {$_.IsRelease -and -not $_.IsSubBranch} } function GetReleaseFixBranches () { GetAllBranches | Where {$_.IsRelease -and $_.IsSubBranch} } function GetHotfixBranches () { GetAllBranches | Where {$_.IsHotfix -and -not $_.IsSubBranch} } function GetHotfixFixBranches () { GetAllBranches | Where {$_.IsHotfix -and $_.IsSubBranch} } function GetFeatureBranches () { GetAllBranches | Where {$_.IsFeature} } function GetAllBranches () { $trackedRefs = @{}; $branches = @{}; git branch --list --all --format='%(refname)>>%(refname:short)>>%(upstream)>>%(HEAD)' | ForEach-Object { $values = $_ -split '>>'; $refName = $values[0]; $shortRefName = $values[1]; $upstream = $values[2]; $isHead = $values[3] -eq '*'; # don't parse manually, use git: # git branch --all --format '%(upstream) %(upstream:short) %(upstream:track) %(upstream:trackshort) %(upstream:remotename) %(upstream:lstrip=3)' # gives: # refs/remotes/origin/feature/vsts-itp origin/feature/vsts-itp [ahead 8] > origin feature/vsts-itp $refParts = $refName -split '/'; $isTracking = [System.String]::IsNullOrEmpty($upstream) -ne $true; if ($isTracking) { $trackedRefs[$upstream] = ''; } $isLocal = $refParts[1] -eq 'heads'; # local - 2: refs/heads/feature/branch_name # remote - 3: refs/remotes/origin/feature/branch_name $typePartIndex = 3; if ($isLocal) { $typePartIndex = 2; } $isFeature = $false; $isRelease = $false; $isHotfix = $false; $type = $refParts[$typePartIndex]; $isFeature = $type -eq 'feature'; $isRelease = $type -eq 'release'; $isHotfix = $type -eq 'hotfix'; $isOther = ($isFeature -or $isRelease -or $isHotfix) -ne $true; $name = $shortRefName; if ($isOther -eq $false) { $nameparts = @($refParts | Select -skip ($typePartIndex + 1)); $name = [System.String]::Join('/', $nameparts); } $localRefParts = @($refParts | Select -Skip ($typePartIndex)); $isSubBranch = $false; if ($localRefParts.Length -gt 2) { $isSubBranch = $true; } $shortLocalRefName = [System.String]::Join('/', $localRefParts); if ($localRefParts.Length -gt 1) { $shortLocalParentRefName = [System.String]::Join('/', @($localRefParts | Select -SkipLast 1)); } $branch = [PSCustomObject]@{ RefName = $refName; ShortRefName = $shortRefName; ShortLocalRefName = $shortLocalRefName; ShortLocalParentRefName = $shortLocalParentRefName; Upstream = $upstream; Name = $name; IsHead = $isHead; IsLocal = $isLocal; IsFeature = $isFeature; IsRelease = $isRelease; Ishotfix = $isHotfix; IsOther = $isOther; IsSubBranch = $isSubBranch; Type = $type; }; $branches[$branch.RefName] = $branch; }; foreach($ref in $trackedRefs.Keys) { $branches.Remove($ref); } $branches.Values | sort RefName; } function NameStartsWith { param($branch, $value) return $branch.Name.StartsWith($value, $true, [System.Globalization.CultureInfo]::InvariantCulture); } function GetStorageFolder () { $moduleName = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath); $applicationDataFolder = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::ApplicationData); return $applicationDataFolder | Join-Path -ChildPath $moduleName; } function ToBranchCompletionResult($branch) { return [System.Management.Automation.CompletionResult]::new($branch.Name, $branch.Name, 'ParameterValue', $branch.RefName); } function GetGitConfigValue { [CmdletBinding()] param ( [string] $key ) ExecuteGitCommand 'git config --get' $key; } function SetGitConfigValue { [CmdletBinding()] param ( [Parameter(Mandatory=$true, Position=0)] [string] $key, [Parameter(Mandatory=$false, Position=1)] [string] $value ) if ([System.String]::IsNullOrEmpty($value)) { ExecuteGitCommand 'git config --unset' $key; } else { ExecuteGitCommand 'git config' "$key '$value'"; } } function ExecuteGitCommand { [CmdletBinding(SupportsShouldProcess=$false)] param( [Parameter(Mandatory=$true, Position=0)] [ValidateNotNullOrEmpty()] [string]$Command, [Parameter(Mandatory=$false, Position=1)] [ValidateNotNullOrEmpty()] [string]$Parameters = '', [Parameter(Mandatory=$false, Position=2)] [ValidateNotNullOrEmpty()] [string]$VerboseParameters = '') if ($VerbosePreference -ne 'SilentlyContinue') { $Command = $Command + " " + $VerboseParameters; } $Command = $Command + " " + $Parameters; if ($VerbosePreference -ne 'SilentlyContinue') { Write-Verbose $Command } iex $Command if ($LASTEXITCODE -ne 0) { throw 'Failed' } } |