From dd320188360fb1a2c2496316e428f6e3c1161696 Mon Sep 17 00:00:00 2001 From: jan De Dobbeleer Date: Thu, 16 Feb 2023 15:18:21 +0100 Subject: [PATCH] feat: add sapling segment --- src/engine/segment.go | 3 + src/segments/git.go | 1 - src/segments/sapling.go | 161 +++++++++++++++++++ src/segments/sapling_test.go | 248 ++++++++++++++++++++++++++++++ src/segments/scm.go | 18 ++- themes/schema.json | 26 ++++ website/docs/segments/git.mdx | 4 +- website/docs/segments/sapling.mdx | 85 ++++++++++ website/sidebars.js | 1 + 9 files changed, 543 insertions(+), 4 deletions(-) create mode 100644 src/segments/sapling.go create mode 100644 src/segments/sapling_test.go create mode 100644 website/docs/segments/sapling.mdx 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/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) + } +} 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",