feat: add sapling segment

This commit is contained in:
jan De Dobbeleer 2023-02-16 15:18:21 +01:00 committed by Jan De Dobbeleer
parent 275723d913
commit dd32018836
9 changed files with 543 additions and 4 deletions

View file

@ -200,6 +200,8 @@ const (
RUBY SegmentType = "ruby"
// RUST writes the cargo version information if cargo.toml is present
RUST SegmentType = "rust"
// SAPLING represents the sapling segment
SAPLING SegmentType = "sapling"
// SESSION represents the user info segment
SESSION SegmentType = "session"
// SHELL writes which shell we're currently in
@ -291,6 +293,7 @@ var Segments = map[SegmentType]func() SegmentWriter{
ROOT: func() SegmentWriter { return &segments.Root{} },
RUBY: func() SegmentWriter { return &segments.Ruby{} },
RUST: func() SegmentWriter { return &segments.Rust{} },
SAPLING: func() SegmentWriter { return &segments.Sapling{} },
SESSION: func() SegmentWriter { return &segments.Session{} },
SHELL: func() SegmentWriter { return &segments.Shell{} },
SPOTIFY: func() SegmentWriter { return &segments.Spotify{} },

View file

@ -128,7 +128,6 @@ type Git struct {
RawUpstreamURL string
UpstreamGone bool
IsWorkTree bool
RepoName string
IsBare bool
// needed for posh-git support

161
src/segments/sapling.go Normal file
View file

@ -0,0 +1,161 @@
package segments
import (
"strings"
"github.com/jandedobbeleer/oh-my-posh/src/platform"
)
// SaplingStatus represents part of the status of a Sapling repository
type SaplingStatus struct {
ScmStatus
}
func (s *SaplingStatus) add(code string) {
// M = modified
// A = added
// R = removed/deleted
// C = clean
// ! = missing (deleted by a non-sl command, but still tracked)
// ? = not tracked
// I = ignored
// = origin of the previous file (with --copies)
switch code {
case "M":
s.Modified++
case "A":
s.Added++
case "R":
s.Deleted++
case "C":
s.Clean++
case "!":
s.Missing++
case "?":
s.Untracked++
case "I":
s.Ignored++
}
}
const (
SAPLINGCOMMAND = "sl"
SLCOMMITTEMPLATE = "no:{node}\nns:{sl_node}\nnd:{sl_date}\nun:{sl_user}\nbm:{activebookmark}"
)
type Sapling struct {
scm
ShortHash string
Hash string
When string
Author string
Bookmark string
Working *SaplingStatus
}
func (sl *Sapling) Template() string {
return " {{ if .Bookmark }}\uf097 {{ .Bookmark }}*{{ else }}\ue729 {{ .ShortHash }}{{ end }}{{ if .Working.Changed }} \uf044 {{ .Working.String }}{{ end }} "
}
func (sl *Sapling) Enabled() bool {
if !sl.shouldDisplay() {
return false
}
sl.setHeadContext()
return true
}
func (sl *Sapling) shouldDisplay() bool {
if !sl.hasCommand(SAPLINGCOMMAND) {
return false
}
slDir, err := sl.env.HasParentFilePath(".sl")
if err != nil {
return false
}
if sl.shouldIgnoreRootRepository(slDir.ParentFolder) {
return false
}
sl.workingDir = slDir.Path
sl.rootDir = slDir.Path
// convert the worktree file path to a windows one when in a WSL shared folder
sl.realDir = strings.TrimSuffix(sl.convertToWindowsPath(slDir.Path), "/.sl")
sl.RepoName = platform.Base(sl.env, sl.convertToLinuxPath(sl.realDir))
sl.setDir(slDir.Path)
return true
}
func (sl *Sapling) setDir(dir string) {
dir = platform.ReplaceHomeDirPrefixWithTilde(sl.env, dir) // align with template PWD
if sl.env.GOOS() == platform.WINDOWS {
sl.Dir = strings.TrimSuffix(dir, `\.sl`)
return
}
sl.Dir = strings.TrimSuffix(dir, "/.sl")
}
func (sl *Sapling) setHeadContext() {
sl.setCommitContext()
sl.Working = &SaplingStatus{}
displayStatus := sl.props.GetBool(FetchStatus, true)
if !displayStatus {
return
}
changes := sl.getSaplingCommandOutput("status")
if len(changes) == 0 {
return
}
lines := strings.Split(changes, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if len(line) == 0 {
continue
}
// element is the element from someSlice for where we are
sl.Working.add(line[0:1])
}
}
func (sl *Sapling) setCommitContext() {
body := sl.getSaplingCommandOutput("log", "--limit", "1", "--template", SLCOMMITTEMPLATE)
splitted := strings.Split(strings.TrimSpace(body), "\n")
for _, line := range splitted {
line = strings.TrimSpace(line)
if len(line) <= 3 {
continue
}
anchor := line[:3]
line = line[3:]
switch anchor {
case "no:":
sl.Hash = line
case "ns:":
sl.ShortHash = line
case "nd:":
sl.When = line
case "un:":
sl.Author = line
case "bm:":
sl.Bookmark = line
}
}
}
func (sl *Sapling) getSaplingCommandOutput(command string, args ...string) string {
args = append([]string{command}, args...)
val, err := sl.env.RunCommand(sl.command, args...)
if err != nil {
return ""
}
return strings.TrimSpace(val)
}

View file

@ -0,0 +1,248 @@
package segments
import (
"errors"
"testing"
"github.com/alecthomas/assert"
"github.com/jandedobbeleer/oh-my-posh/src/mock"
"github.com/jandedobbeleer/oh-my-posh/src/platform"
"github.com/jandedobbeleer/oh-my-posh/src/properties"
)
func TestSetDir(t *testing.T) {
cases := []struct {
Case string
Expected string
Path string
GOOS string
}{
{
Case: "In home folder",
Expected: "~/sapling",
Path: "/usr/home/sapling/.sl",
GOOS: platform.LINUX,
},
{
Case: "Outside home folder",
Expected: "/usr/sapling/repo",
Path: "/usr/sapling/repo/.sl",
GOOS: platform.LINUX,
},
{
Case: "Windows home folder",
Expected: "~\\sapling",
Path: "\\usr\\home\\sapling\\.sl",
GOOS: platform.WINDOWS,
},
{
Case: "Windows outside home folder",
Expected: "\\usr\\sapling\\repo",
Path: "\\usr\\sapling\\repo\\.sl",
GOOS: platform.WINDOWS,
},
}
for _, tc := range cases {
env := new(mock.MockedEnvironment)
env.On("GOOS").Return(tc.GOOS)
home := "/usr/home"
if tc.GOOS == platform.WINDOWS {
home = "\\usr\\home"
}
env.On("Home").Return(home)
sl := &Sapling{
scm: scm{
env: env,
},
}
sl.setDir(tc.Path)
assert.Equal(t, tc.Expected, sl.Dir, tc.Case)
}
}
func TestSetCommitContext(t *testing.T) {
cases := []struct {
Case string
Output string
Error error
ExpectedHash string
ExpectedShortHash string
ExpectedWhen string
ExpectedAuthor string
ExpectedBookmark string
}{
{
Case: "Error",
Error: errors.New("error"),
},
{
Case: "No output",
},
{
Case: "All output",
Output: `
no:734349e9f1abd229ec6e9bbebed35aed56b26a9e
ns:734349e9f
nd:23 minutes ago
un:jan
bm:sapling-segment
`,
ExpectedHash: "734349e9f1abd229ec6e9bbebed35aed56b26a9e",
ExpectedShortHash: "734349e9f",
ExpectedWhen: "23 minutes ago",
ExpectedAuthor: "jan",
ExpectedBookmark: "sapling-segment",
},
{
Case: "Short line",
Output: "er",
},
}
for _, tc := range cases {
env := new(mock.MockedEnvironment)
env.On("RunCommand", "sl", []string{"log", "--limit", "1", "--template", SLCOMMITTEMPLATE}).Return(tc.Output, tc.Error)
sl := &Sapling{
scm: scm{
env: env,
command: SAPLINGCOMMAND,
},
}
sl.setCommitContext()
assert.Equal(t, tc.ExpectedHash, sl.Hash, tc.Case)
assert.Equal(t, tc.ExpectedShortHash, sl.ShortHash, tc.Case)
assert.Equal(t, tc.ExpectedWhen, sl.When, tc.Case)
assert.Equal(t, tc.ExpectedAuthor, sl.Author, tc.Case)
assert.Equal(t, tc.ExpectedBookmark, sl.Bookmark, tc.Case)
}
}
func TestShouldDisplay(t *testing.T) {
cases := []struct {
Case string
HasSapling bool
InRepo bool
Expected bool
Excluded bool
}{
{
Case: "Sapling not installed",
},
{
Case: "Sapling installed, not in repo",
HasSapling: true,
},
{
Case: "Sapling installed, in repo but ignored",
HasSapling: true,
InRepo: true,
Excluded: true,
},
{
Case: "Sapling installed, in repo",
HasSapling: true,
InRepo: true,
Expected: true,
},
}
fileInfo := &platform.FileInfo{
Path: "/sapling/repo/.sl",
ParentFolder: "/sapling/repo",
IsDir: true,
}
for _, tc := range cases {
env := new(mock.MockedEnvironment)
env.On("HasCommand", "sl").Return(tc.HasSapling)
env.On("InWSLSharedDrive").Return(false)
env.On("GOOS").Return(platform.LINUX)
env.On("Home").Return("/usr/home/sapling")
env.On("DirMatchesOneOf", fileInfo.ParentFolder, []string{"/sapling/repo"}).Return(tc.Excluded)
if tc.InRepo {
env.On("HasParentFilePath", ".sl").Return(fileInfo, nil)
} else {
env.On("HasParentFilePath", ".sl").Return(&platform.FileInfo{}, errors.New("error"))
}
sl := &Sapling{
scm: scm{
env: env,
props: &properties.Map{
properties.ExcludeFolders: []string{"/sapling/repo"},
},
},
}
got := sl.shouldDisplay()
assert.Equal(t, tc.Expected, got, tc.Case)
if tc.Expected {
assert.Equal(t, "/sapling/repo/.sl", sl.workingDir, tc.Case)
assert.Equal(t, "/sapling/repo/.sl", sl.rootDir, tc.Case)
assert.Equal(t, "/sapling/repo", sl.realDir, tc.Case)
assert.Equal(t, "repo", sl.RepoName, tc.Case)
}
}
}
func TestSetHeadContext(t *testing.T) {
cases := []struct {
Case string
FetchStatus bool
Output string
Expected string
}{
{
Case: "Do not fetch status",
},
{
Case: "Fetch status, no output",
FetchStatus: true,
},
{
Case: "Fetch status, changed files",
FetchStatus: true,
Output: `
M file.go
M file2.go
`,
Expected: "~2",
},
{
Case: "Fetch status, all cases",
FetchStatus: true,
Output: `
M file.go
R file2.go
A file3.go
C file4.go
! missing.go
? untracked.go
? untracked.go
I ignored.go
I ignored.go
`,
Expected: "?2 +1 ~1 -1 !1 =1 Ø2",
},
}
output := `
no:734349e9f1abd229ec6e9bbebed35aed56b26a9e
ns:734349e9f
nd:23 minutes ago
un:jan
bm:sapling-segment
`
for _, tc := range cases {
env := new(mock.MockedEnvironment)
env.On("RunCommand", "sl", []string{"log", "--limit", "1", "--template", SLCOMMITTEMPLATE}).Return(output, nil)
env.On("RunCommand", "sl", []string{"status"}).Return(tc.Output, nil)
sl := &Sapling{
scm: scm{
env: env,
props: &properties.Map{
FetchStatus: tc.FetchStatus,
},
command: SAPLINGCOMMAND,
},
}
sl.setHeadContext()
got := sl.Working.String()
assert.Equal(t, tc.Expected, got, tc.Case)
}
}

View file

@ -22,10 +22,22 @@ type ScmStatus struct {
Moved int
Conflicted int
Untracked int
Clean int
Missing int
Ignored int
}
func (s *ScmStatus) Changed() bool {
return s.Added > 0 || s.Deleted > 0 || s.Modified > 0 || s.Unmerged > 0 || s.Moved > 0 || s.Conflicted > 0 || s.Untracked > 0
return s.Unmerged > 0 ||
s.Added > 0 ||
s.Deleted > 0 ||
s.Modified > 0 ||
s.Moved > 0 ||
s.Conflicted > 0 ||
s.Untracked > 0 ||
s.Clean > 0 ||
s.Missing > 0 ||
s.Ignored > 0
}
func (s *ScmStatus) String() string {
@ -43,6 +55,9 @@ func (s *ScmStatus) String() string {
status += stringIfValue(s.Moved, ">")
status += stringIfValue(s.Unmerged, "x")
status += stringIfValue(s.Conflicted, "!")
status += stringIfValue(s.Missing, "!")
status += stringIfValue(s.Clean, "=")
status += stringIfValue(s.Ignored, "Ø")
return strings.TrimSpace(status)
}
@ -53,6 +68,7 @@ type scm struct {
IsWslSharedPath bool
CommandMissing bool
Dir string // actual repo root directory
RepoName string
workingDir string
rootDir string

View file

@ -289,6 +289,7 @@
"ruby",
"rust",
"r",
"sapling",
"session",
"spotify",
"shell",
@ -1822,6 +1823,31 @@
"description": "https://ohmyposh.dev/docs/segments/root"
}
},
{
"if": {
"properties": {
"type": {
"const": "sapling"
}
}
},
"then": {
"title": "Sapling Segment",
"description": "https://ohmyposh.dev/docs/segments/sapling",
"properties": {
"properties": {
"properties": {
"fetch_status": {
"type": "boolean",
"title": "Display Status",
"description": "Display the local changes or not",
"default": true
}
}
}
}
}
},
{
"if": {
"properties": {

View file

@ -164,8 +164,8 @@ You can set the following properties to `true` to enable fetching additional inf
| Name | Type | Description |
| ------------ | ----------- | -------------------------------------- |
| `.Author` | `User` | the author or the commit (see below) |
| `.Committer` | `User` | the comitter or the commit (see below) |
| `.Author` | `User` | the author of the commit (see below) |
| `.Committer` | `User` | the comitter of the commit (see below) |
| `.Subject` | `string` | the commit subject |
| `.Timestamp` | `time.Time` | the commit timestamp |

View file

@ -0,0 +1,85 @@
---
id: sapling
title: Sapling
sidebar_label: Sapling
---
## What
Display [sapling][sapling] information when in a sapling repository.
## Sample Configuration
```json
{
"type": "sapling",
"style": "powerline",
"powerline_symbol": "\uE0B0",
"foreground": "#193549",
"background": "#4C9642",
"background_templates": ["{{ if .Bookmark }}#4C9642{{ end }}"],
"properties": {
"fetch_status": true
}
}
```
## Properties
### Fetching information
| Name | Type | Description |
| ----------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fetch_status` | `boolean` | fetch the local changes - defaults to `true` |
| `native_fallback` | `boolean` | when set to `true` and `sl.exe` is not available when inside a WSL2 shared Windows drive, we will fallback to the native sapling executable to fetch data. Not all information can be displayed in this case. Defaults to `false` |
## Template ([info][templates])
:::note default template
```template
{{ if .Bookmark }}\uf097 {{ .Bookmark }}*{{ else }}\ue729 {{ .ShortHash }}{{ end }}{{ if .Working.Changed }} \uf044 {{ .Working.String }}{{ end }}
```
:::
### Properties
| Name | Type | Description |
| ------------ | --------------- | ------------------------------------- |
| `.RepoName` | `string` | the repo folder name |
| `.Working` | `SaplingStatus` | changes in the worktree (see below) |
| `.Author` | `string` | the author of the commit |
| `.Hash` | `string` | the full hash of the commit |
| `.ShortHash` | `string` | the short hash of the commit |
| `.When` | `string` | the commit's relative time indication |
| `.Bookmark` | `string` | the commit's bookmark (if any) |
| `.Dir` | `string` | the repository's root directory |
### SaplingStatus
| Name | Type | Description |
| ------------ | --------- | -------------------------------------------- |
| `.Modified` | `int` | number of modified changes |
| `.Added` | `int` | number of added changes |
| `.Deleted` | `int` | number of removed changes |
| `.Untracked` | `boolean` | number of untracked changes |
| `.Clean` | `int` | number of clean changes |
| `.Missing` | `int` | number of missing changes |
| `.Ignored` | `boolean` | number of ignored changes |
| `.String` | `string` | a string representation of the changes above |
Local changes use the following syntax:
| Icon | Description |
| ---- | ----------- |
| `+` | added |
| `~` | modified |
| `-` | deleted |
| `?` | untracked |
| `=` | clean |
| `!` | missing |
| `Ø` | ignored |
[sapling]: https://sapling-scm.com/
[templates]: /docs/configuration/templates

View file

@ -103,6 +103,7 @@ module.exports = {
"segments/root",
"segments/ruby",
"segments/rust",
"segments/sapling",
"segments/session",
"segments/shell",
"segments/spotify",