feat(git): mapped branches

resolves #4979
This commit is contained in:
Jan De Dobbeleer 2024-07-26 23:06:10 +02:00 committed by Jan De Dobbeleer
parent 1c908a8ce6
commit 7a6478269c
10 changed files with 195 additions and 137 deletions

View file

@ -35,7 +35,6 @@ require (
github.com/goccy/go-yaml v1.11.3
github.com/gookit/goutil v0.6.16
github.com/hashicorp/hcl/v2 v2.21.0
github.com/mattn/go-runewidth v0.0.16
github.com/pelletier/go-toml/v2 v2.2.2
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
@ -79,6 +78,7 @@ require (
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect

View file

@ -111,6 +111,8 @@ const (
UntrackedModes properties.Property = "untracked_modes"
// IgnoreSubmodules list the optional ignore-submodules mode per repo
IgnoreSubmodules properties.Property = "ignore_submodules"
// MappedBranches allows overriding certain branches with an icon/text
MappedBranches properties.Property = "mapped_branches"
DETACHED = "(detached)"
BRANCHPREFIX = "ref: refs/heads/"
@ -333,7 +335,7 @@ func (g *Git) getBareRepoInfo() {
head := g.FileContents(g.workingDir, "HEAD")
branchIcon := g.props.GetString(BranchIcon, "\uE0A0")
g.Ref = strings.Replace(head, "ref: refs/heads/", "", 1)
g.HEAD = fmt.Sprintf("%s%s", branchIcon, g.Ref)
g.HEAD = fmt.Sprintf("%s%s", branchIcon, g.formatBranch(g.Ref))
if !g.props.GetBool(FetchUpstreamIcon, false) {
return
}
@ -550,23 +552,28 @@ func (g *Git) setGitStatus() {
g.Working.add(workingCode)
g.Staging.add(stagingCode)
}
const (
HASH = "# branch.oid "
REF = "# branch.head "
UPSTREAM = "# branch.upstream "
BRANCHSTATUS = "# branch.ab "
)
// firstly assume that upstream is gone
g.UpstreamGone = true
statusFormats := g.props.GetKeyValueMap(StatusFormats, map[string]string{})
g.Working = &GitStatus{ScmStatus: ScmStatus{Formats: statusFormats}}
g.Staging = &GitStatus{ScmStatus: ScmStatus{Formats: statusFormats}}
untrackedMode := g.getUntrackedFilesMode()
args := []string{"status", untrackedMode, "--branch", "--porcelain=2"}
ignoreSubmodulesMode := g.getIgnoreSubmodulesMode()
if len(ignoreSubmodulesMode) > 0 {
args = append(args, ignoreSubmodulesMode)
}
output := g.getGitCommandOutput(args...)
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, HASH) && len(line) >= len(HASH)+7 {
@ -574,16 +581,19 @@ func (g *Git) setGitStatus() {
g.Hash = line[len(HASH):]
continue
}
if strings.HasPrefix(line, REF) && len(line) > len(REF) {
g.Ref = line[len(REF):]
continue
}
if strings.HasPrefix(line, UPSTREAM) && len(line) > len(UPSTREAM) {
// status reports upstream, but upstream may be gone (must check BRANCHSTATUS)
g.Upstream = line[len(UPSTREAM):]
g.UpstreamGone = true
continue
}
if strings.HasPrefix(line, BRANCHSTATUS) && len(line) > len(BRANCHSTATUS) {
status := line[len(BRANCHSTATUS):]
splitted := strings.Split(status, " ")
@ -596,6 +606,7 @@ func (g *Git) setGitStatus() {
g.UpstreamGone = false
continue
}
addToStatus(line)
}
}
@ -615,7 +626,7 @@ func (g *Git) setGitHEADContext() {
g.Detached = true
g.setPrettyHEADName()
} else {
head := g.formatHEAD(g.Ref)
head := g.formatBranch(g.Ref)
g.HEAD = fmt.Sprintf("%s%s", branchIcon, head)
}
@ -633,7 +644,7 @@ func (g *Git) setGitHEADContext() {
origin = formatDetached()
} else {
head = strings.Replace(head, "refs/heads/", "", 1)
origin = branchIcon + g.formatHEAD(head)
origin = branchIcon + g.formatBranch(head)
}
return origin
}
@ -642,7 +653,7 @@ func (g *Git) setGitHEADContext() {
g.Rebase = true
origin := getPrettyNameOrigin("rebase-merge/head-name")
onto := g.getGitRefFileSymbolicName("rebase-merge/onto")
onto = g.formatHEAD(onto)
onto = g.formatBranch(onto)
step := g.FileContents(g.workingDir, "rebase-merge/msgnum")
total := g.FileContents(g.workingDir, "rebase-merge/end")
icon := g.props.GetString(RebaseIcon, "\uE728 ")
@ -680,7 +691,7 @@ func (g *Git) setGitHEADContext() {
theirs = g.formatSHA(matches["theirs"])
default:
headIcon = branchIcon
theirs = g.formatHEAD(matches["theirs"])
theirs = g.formatBranch(matches["theirs"])
}
g.HEAD = fmt.Sprintf("%s%s%s into %s", icon, headIcon, theirs, formatDetached())
return
@ -732,15 +743,6 @@ func (g *Git) setGitHEADContext() {
g.HEAD = formatDetached()
}
func (g *Git) formatHEAD(head string) string {
maxLength := g.props.GetInt(BranchMaxLength, 0)
if maxLength == 0 || len(head) < maxLength {
return head
}
symbol := g.props.GetString(TruncateSymbol, "")
return head[0:maxLength] + symbol
}
func (g *Git) formatSHA(sha string) string {
if len(sha) <= 7 {
return sha
@ -765,7 +767,7 @@ func (g *Git) setPrettyHEADName() {
if strings.HasPrefix(HEADRef, BRANCHPREFIX) {
branchName := strings.TrimPrefix(HEADRef, BRANCHPREFIX)
g.Ref = branchName
g.HEAD = fmt.Sprintf("%s%s", g.props.GetString(BranchIcon, "\uE0A0"), g.formatHEAD(branchName))
g.HEAD = fmt.Sprintf("%s%s", g.props.GetString(BranchIcon, "\uE0A0"), g.formatBranch(branchName))
return
}
// no branch, points to commit

View file

@ -140,23 +140,27 @@ func (p *Plastic) getHeadChangeset() int {
func (p *Plastic) setSelector() {
var ref string
selector := p.FileContents(p.plasticWorkspaceFolder+"/.plastic/", "plastic.selector")
// changeset
ref = p.parseChangesetSelector(selector)
if len(ref) > 0 {
p.Selector = fmt.Sprintf("%s%s", p.props.GetString(CommitIcon, "\uF417"), ref)
return
}
// fallback to label
ref = p.parseLabelSelector(selector)
if len(ref) > 0 {
p.Selector = fmt.Sprintf("%s%s", p.props.GetString(TagIcon, "\uF412"), ref)
return
}
// fallback to branch/smartbranch
ref = p.parseBranchSelector(selector)
if len(ref) > 0 {
ref = p.truncateBranch(ref)
ref = p.formatBranch(ref)
}
p.Selector = fmt.Sprintf("%s%s", p.props.GetString(BranchIcon, "\uE0A0"), ref)
}

View file

@ -93,5 +93,5 @@ func (g *Git) parsePoshGitHEAD(head string) string {
return fmt.Sprintf("%s%s", g.props.GetString(TagIcon, "\uF412"), head)
}
// regular branch
return fmt.Sprintf("%s%s", g.props.GetString(BranchIcon, "\uE0A0"), g.formatHEAD(head))
return fmt.Sprintf("%s%s", g.props.GetString(BranchIcon, "\uE0A0"), g.formatBranch(head))
}

View file

@ -3,6 +3,7 @@ package segments
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/jandedobbeleer/oh-my-posh/src/properties"
"github.com/jandedobbeleer/oh-my-posh/src/runtime"
@ -109,21 +110,40 @@ func (s *scm) Init(props properties.Properties, env runtime.Environment) {
s.env = env
}
func (s *scm) truncateBranch(branch string) string {
fullBranchPath := s.props.GetBool(FullBranchPath, true)
maxLength := s.props.GetInt(BranchMaxLength, 0)
func (s *scm) formatBranch(branch string) string {
mappedBranches := s.props.GetKeyValueMap(MappedBranches, make(map[string]string))
for key, value := range mappedBranches {
matchSubFolders := strings.HasSuffix(key, "*")
if matchSubFolders && len(key) > 1 {
key = key[0 : len(key)-1] // remove trailing /* or \*
}
if !strings.HasPrefix(branch, key) {
continue
}
branch = strings.Replace(branch, key, value, 1)
break
}
fullBranchPath := s.props.GetBool(FullBranchPath, true)
if !fullBranchPath && strings.Contains(branch, "/") {
index := strings.LastIndex(branch, "/")
branch = branch[index+1:]
}
maxLength := s.props.GetInt(BranchMaxLength, 0)
if maxLength == 0 || len(branch) <= maxLength {
return branch
}
symbol := s.props.GetString(TruncateSymbol, "")
return branch[0:maxLength] + symbol
truncateSymbol := s.props.GetString(TruncateSymbol, "")
lenTruncateSymbol := utf8.RuneCountInString(truncateSymbol)
maxLength -= lenTruncateSymbol
runes := []rune(branch)
return string(runes[0:maxLength]) + truncateSymbol
}
func (s *scm) shouldIgnoreRootRepository(rootDir string) bool {

View file

@ -105,78 +105,6 @@ func TestScmStatusString(t *testing.T) {
}
}
func TestTruncateBranch(t *testing.T) {
cases := []struct {
Case string
Expected string
Branch string
FullBranch bool
MaxLength any
}{
{Case: "No limit", Expected: "are-belong-to-us", Branch: "/all-your-base/are-belong-to-us", FullBranch: false},
{Case: "No limit - larger", Expected: "are-belong", Branch: "/all-your-base/are-belong-to-us", FullBranch: false, MaxLength: 10.0},
{Case: "No limit - smaller", Expected: "all-your-base", Branch: "/all-your-base", FullBranch: false, MaxLength: 13.0},
{Case: "Invalid setting", Expected: "all-your-base", Branch: "/all-your-base", FullBranch: false, MaxLength: "burp"},
{Case: "Lower than limit", Expected: "all-your-base", Branch: "/all-your-base", FullBranch: false, MaxLength: 20.0},
{Case: "No limit - full branch", Expected: "/all-your-base/are-belong-to-us", Branch: "/all-your-base/are-belong-to-us", FullBranch: true},
{Case: "No limit - larger - full branch", Expected: "/all-your-base", Branch: "/all-your-base/are-belong-to-us", FullBranch: true, MaxLength: 14.0},
{Case: "No limit - smaller - full branch ", Expected: "/all-your-base", Branch: "/all-your-base", FullBranch: true, MaxLength: 14.0},
{Case: "Invalid setting - full branch", Expected: "/all-your-base", Branch: "/all-your-base", FullBranch: true, MaxLength: "burp"},
{Case: "Lower than limit - full branch", Expected: "/all-your-base", Branch: "/all-your-base", FullBranch: true, MaxLength: 20.0},
}
for _, tc := range cases {
props := properties.Map{
BranchMaxLength: tc.MaxLength,
FullBranchPath: tc.FullBranch,
}
p := &Plastic{
scm: scm{
props: props,
},
}
assert.Equal(t, tc.Expected, p.truncateBranch(tc.Branch), tc.Case)
}
}
func TestTruncateBranchWithSymbol(t *testing.T) {
cases := []struct {
Case string
Expected string
Branch string
FullBranch bool
MaxLength any
TruncateSymbol any
}{
{Case: "No limit", Expected: "are-belong-to-us", Branch: "/all-your-base/are-belong-to-us", FullBranch: false, TruncateSymbol: "..."},
{Case: "No limit - larger", Expected: "are-belong...", Branch: "/all-your-base/are-belong-to-us", FullBranch: false, MaxLength: 10.0, TruncateSymbol: "..."},
{Case: "No limit - smaller", Expected: "all-your-base", Branch: "/all-your-base", FullBranch: false, MaxLength: 13.0, TruncateSymbol: "..."},
{Case: "Invalid setting", Expected: "all-your-base", Branch: "/all-your-base", FullBranch: false, MaxLength: "burp", TruncateSymbol: "..."},
{Case: "Lower than limit", Expected: "all-your-base", Branch: "/all-your-base", FullBranch: false, MaxLength: 20.0, TruncateSymbol: "..."},
{Case: "No limit - full branch", Expected: "/all-your-base/are-belong-to-us", Branch: "/all-your-base/are-belong-to-us", FullBranch: true, TruncateSymbol: "..."},
{Case: "No limit - larger - full branch", Expected: "/all-your-base...", Branch: "/all-your-base/are-belong-to-us", FullBranch: true, MaxLength: 14.0, TruncateSymbol: "..."},
{Case: "No limit - smaller - full branch ", Expected: "/all-your-base", Branch: "/all-your-base", FullBranch: true, MaxLength: 14.0, TruncateSymbol: "..."},
{Case: "Invalid setting - full branch", Expected: "/all-your-base", Branch: "/all-your-base", FullBranch: true, MaxLength: "burp", TruncateSymbol: "..."},
{Case: "Lower than limit - full branch", Expected: "/all-your-base", Branch: "/all-your-base", FullBranch: true, MaxLength: 20.0, TruncateSymbol: "..."},
}
for _, tc := range cases {
props := properties.Map{
BranchMaxLength: tc.MaxLength,
TruncateSymbol: tc.TruncateSymbol,
FullBranchPath: tc.FullBranch,
}
p := &Plastic{
scm: scm{
props: props,
},
}
assert.Equal(t, tc.Expected, p.truncateBranch(tc.Branch), tc.Case)
}
}
func TestHasCommand(t *testing.T) {
cases := []struct {
Case string
@ -212,3 +140,90 @@ func TestHasCommand(t *testing.T) {
assert.Equal(t, tc.ExpectedCommand, s.command, tc.Case)
}
}
func TestFormatBranch(t *testing.T) {
cases := []struct {
Case string
Expected string
Input string
MappedBranches map[string]string
BranchMaxLength int
TruncateSymbol string
NoFullBranchPath bool
}{
{
Case: "No settings",
Input: "main",
Expected: "main",
},
{
Case: "BranchMaxLength higher than branch name",
Input: "main",
Expected: "main",
BranchMaxLength: 10,
},
{
Case: "BranchMaxLength lower than branch name",
Input: "feature/test-this-branch",
Expected: "featu",
BranchMaxLength: 5,
},
{
Case: "BranchMaxLength lower than branch name, with truncate symbol",
Input: "feature/test-this-branch",
Expected: "feat…",
BranchMaxLength: 5,
TruncateSymbol: "…",
},
{
Case: "BranchMaxLength lower than branch name, with truncate symbol and no FullBranchPath",
Input: "feature/test-this-branch",
Expected: "test…",
BranchMaxLength: 5,
TruncateSymbol: "…",
NoFullBranchPath: true,
},
{
Case: "BranchMaxLength lower to branch name, with truncate symbol",
Input: "feat",
Expected: "feat",
BranchMaxLength: 5,
TruncateSymbol: "…",
},
{
Case: "Branch mapping, no BranchMaxLength",
Input: "feat/my-new-feature",
Expected: "🚀 my-new-feature",
MappedBranches: map[string]string{
"feat/*": "🚀 ",
"bug/*": "🐛 ",
},
},
{
Case: "Branch mapping, with BranchMaxLength",
Input: "feat/my-new-feature",
Expected: "🚀 my-",
BranchMaxLength: 5,
MappedBranches: map[string]string{
"feat/*": "🚀 ",
"bug/*": "🐛 ",
},
},
}
for _, tc := range cases {
g := &Git{
scm: scm{
props: properties.Map{
MappedBranches: tc.MappedBranches,
BranchMaxLength: tc.BranchMaxLength,
TruncateSymbol: tc.TruncateSymbol,
FullBranchPath: !tc.NoFullBranchPath,
},
},
}
got := g.formatBranch(tc.Input)
assert.Equal(t, tc.Expected, got, tc.Case)
}
}

View file

@ -4,18 +4,14 @@ import (
"fmt"
"os"
"strings"
"unicode/utf8"
"github.com/jandedobbeleer/oh-my-posh/src/color"
"github.com/jandedobbeleer/oh-my-posh/src/log"
"github.com/jandedobbeleer/oh-my-posh/src/regex"
"github.com/jandedobbeleer/oh-my-posh/src/shell"
"github.com/mattn/go-runewidth"
)
func init() {
runewidth.DefaultCondition.EastAsianWidth = false
}
type style struct {
AnchorStart string
AnchorEnd string
@ -400,7 +396,7 @@ func write(s rune) {
}
}
length += runewidth.RuneWidth(s)
length += utf8.RuneCountInString(string(s))
lastRune = s
builder.WriteRune(s)
}

View file

@ -144,9 +144,15 @@
"full_branch_path": {
"type": "boolean",
"title": "Full branch path",
"description": "display the full branch path instead of only the branch name",
"description": "display the full branch path instead of only the last part (e.g. feature/branch instead of branch)",
"default": true
},
"mapped_branches": {
"type": "object",
"title": "Mapped Branches",
"description": "Custom glyph/text for specific branches",
"default": {}
},
"extra_prompt": {
"type": "object",
"default": {},
@ -1467,6 +1473,12 @@
"title": "Status string formats",
"description": "a key, value map representing the remote URL (or a part of that URL) and icon to use in case the upstream URL contains the key. These get precedence over the standard icons",
"default": {}
},
"mapped_branches": {
"$ref": "#/definitions/mapped_branches"
},
"full_branch_path": {
"$ref": "#/definitions/full_branch_path"
}
}
}
@ -3655,6 +3667,9 @@
},
"native_fallback": {
"$ref": "#/definitions/native_fallback"
},
"mapped_branches": {
"$ref": "#/definitions/mapped_branches"
}
}
}

View file

@ -9,26 +9,6 @@ sidebar_label: Git
Display git information when in a git repository. Also works for subfolders. For maximum compatibility,
make sure your `git` executable is up-to-date (when branch or status information is incorrect for example).
:::tip
If you want to display the default [posh-git][poshgit] output, **do not** use this segment
but add the following snippet after initializing Oh My Posh in your `$PROFILE`:
```powershell
function Set-PoshGitStatus {
$global:GitStatus = Get-GitStatus
$env:POSH_GIT_STRING = Write-GitStatus -Status $global:GitStatus
}
New-Alias -Name 'Set-PoshContext' -Value 'Set-PoshGitStatus' -Scope Global -Force
```
You can then use the `POSH_GIT_STRING` environment variable in a [text segment][text]:
```json
"template": "{{ if .Env.POSH_GIT_STRING }} {{ .Env.POSH_GIT_STRING }} {{ end }}"
```
:::
## Sample Configuration
import Config from "@site/src/components/Config.js";
@ -55,6 +35,10 @@ import Config from "@site/src/components/Config.js";
"/Users/user/Projects/oh-my-posh/": "no",
},
source: "cli",
mapped_branches: {
"feat/*": "🚀 ",
"bug/*": "🐛 ",
},
},
}}
/>
@ -78,6 +62,8 @@ You can set the following properties to `true` to enable fetching additional inf
| `fetch_user` | [`User`](#user) | `false` | fetch the current configured user for the repository |
| `status_formats` | `map[string]string` | | a key, value map allowing to override how individual status items are displayed. For example, `"status_formats": { "Added": "Added: %d" }` will display the added count as `Added: 1` instead of `+1`. See the [Status](#status) section for available overrides. |
| `source` | `string` | `cli` | <ul><li>`cli`: fetch the information using the git CLI</li><li>`pwsh`: fetch the information from the [posh-git][poshgit] PowerShell Module</li></ul> |
| `mapped_branches` | `object` | | custom glyph/text for specific branches. You can use `*` at the end as a wildcard character for matching |
| `full_branch_path` | `bool` | `true` | display the full branch path instead of only the last part (e.g. `feature/branch` instead of `branch`) |
### Icons
@ -197,11 +183,30 @@ Local changes use the following syntax:
| `.Name` | `string` | the user's name |
| `.Email` | `string` | the user's email |
## posh-git
If you want to display the default [posh-git][poshgit] output, **do not** use this segment
but add the following snippet after initializing Oh My Posh in your `$PROFILE`:
```powershell
function Set-PoshGitStatus {
$global:GitStatus = Get-GitStatus
$env:POSH_GIT_STRING = Write-GitStatus -Status $global:GitStatus
}
New-Alias -Name 'Set-PoshContext' -Value 'Set-PoshGitStatus' -Scope Global -Force
```
You can then use the `POSH_GIT_STRING` environment variable in a [text segment][text]:
```json
"template": "{{ if .Env.POSH_GIT_STRING }} {{ .Env.POSH_GIT_STRING }} {{ end }}"
```
[poshgit]: https://github.com/dahlbyk/posh-git
[templates]: /docs/configuration/templates
[hyperlinks]: /docs/configuration/templates#custom
[untracked]: https://git-scm.com/docs/git-status#Documentation/git-status.txt---untracked-filesltmodegt
[submodules]: https://git-scm.com/docs/git-status#Documentation/git-status.txt---ignore-submodulesltwhengt
[kraken-ref]: https://www.gitkraken.com/invite/nQmDPR9D
[text]: text.mdx
[text]: /docs/segments/system/text
[exclude_folders]: /docs/configuration/segment#include--exclude-folders

View file

@ -60,11 +60,12 @@ You can set the following property to `true` to enable fetching additional infor
#### Branch
| Name | Type | Default | Description |
| ------------------- | :------: | :------: | -------------------------------------------------------------------------- |
| ------------------- | :------: | :------: | -------------------------------------------------------------------------------------------------------- |
| `branch_icon` | `string` | `\uE0A0` | the icon to use in front of the git branch name |
| `full_branch_path` | `bool` | `true` | display the full branch path: `_/main/fix-001_` instead of `_fix-001_` |
| `full_branch_path` | `bool` | `true` | display the full branch path instead of only the last part (e.g. `feature/branch` instead of `branch`) |
| `branch_max_length` | `int` | `0` | the max length for the displayed branch name where `0` implies full length |
| `truncate_symbol` | `string` | | the icon to display when a branch name is truncated |
| `mapped_branches` | `object` | | custom glyph/text for specific branches. You can use `*` at the end as a wildcard character for matching |
#### Selector
@ -86,7 +87,7 @@ You can set the following property to `true` to enable fetching additional infor
### Properties
| Name | Type | Description |
| --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `.Selector` | `string` | the current selector context (branch/changeset/label) |
| `.Behind` | `bool` | the current workspace is behind and changes are incoming |
| `.Status` | `Status` | changes in the workspace (see below) |