Repository: JanDeDobbeleer/oh-my-posh
Pull request #3499 (2023-02-16 14:18:35)

feat: add sapling segment
feat: add sapling segment

---
Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/JanDeDobbeleer/oh-my-posh/pull/3499).
* __->__ #3499



Patch:
diff --git a/src/engine/segment.go b/src/engine/segment.go
index a44b0646..640a148f 100644
--- a/src/engine/segment.go
+++ b/src/engine/segment.go
@@ -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{} },
diff --git a/src/segments/git.go b/src/segments/git.go
index d750ab5c..6a8a7a68 100644
--- a/src/segments/git.go
+++ b/src/segments/git.go
@@ -128,7 +128,6 @@ type Git struct {
 	RawUpstreamURL string
 	UpstreamGone   bool
 	IsWorkTree     bool
-	RepoName       string
 	IsBare         bool
 
 	// needed for posh-git support
diff --git a/src/segments/sapling.go b/src/segments/sapling.go
new file mode 100644
index 00000000..90013da3
--- /dev/null
+++ b/src/segments/sapling.go
@@ -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)
+}
diff --git a/src/segments/scm.go b/src/segments/scm.go
index d346834d..c452384b 100644
--- a/src/segments/scm.go
+++ b/src/segments/scm.go
@@ -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
diff --git a/themes/schema.json b/themes/schema.json
index 30fa35a3..a66a0b4e 100644
--- a/themes/schema.json
+++ b/themes/schema.json
@@ -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": {
diff --git a/website/docs/segments/git.mdx b/website/docs/segments/git.mdx
index 06ac8227..7b981a8b 100644
--- a/website/docs/segments/git.mdx
+++ b/website/docs/segments/git.mdx
@@ -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                   |
 
diff --git a/website/docs/segments/sapling.mdx b/website/docs/segments/sapling.mdx
new file mode 100644
index 00000000..d8ddfcc1
--- /dev/null
+++ b/website/docs/segments/sapling.mdx
@@ -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
diff --git a/website/sidebars.js b/website/sidebars.js
index 66925590..d8bc0f89 100644
--- a/website/sidebars.js
+++ b/website/sidebars.js
@@ -103,6 +103,7 @@ module.exports = {
         "segments/root",
         "segments/ruby",
         "segments/rust",
+        "segments/sapling",
         "segments/session",
         "segments/shell",
         "segments/spotify",


Test patch:
diff --git a/src/segments/sapling_test.go b/src/segments/sapling_test.go
new file mode 100644
index 00000000..11dbc46a
--- /dev/null
+++ b/src/segments/sapling_test.go
@@ -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)
+	}
+}