feat(plastic): add Plastic SCM segment

* feat(plastic): added Plastic SCM segment

* refactor(plastic): polished new Plastic SCM segment

* refactor: moved common scm segment code into base type

git and plastic share some common methods and status properties. So moving them in a base type keeps the code base DRY

* doc(plastic): Added docs for manual testing Plastic SCM

* fix(plastic): Show files with merge conflicts as unmerged

* fix(plastic): adhere to empty string check guidelines

* fix(plastic): fixed linter errors

* fix(pwsh): alert when we can't download dependencies

resolves #1382

* refactor(plastic): polished new Plastic SCM segment

* refactor: moved common scm segment code into base type

git and plastic share some common methods and status properties

* docs(plastic): added docs for manual testing Plastic SCM

* fix(plastic): show files with merge conflicts as unmerged

* fix(plastic): adhere to empty string check guidelines

* fix(plastic): fixed linter errors

Co-authored-by: Jan De Dobbeleer <jan.de.dobbeleer@gmail.com>
Co-authored-by: Jan De Dobbeleer <2492783+JanDeDobbeleer@users.noreply.github.com>
This commit is contained in:
Khaos 2021-12-11 22:08:47 +01:00 committed by GitHub
parent d52f917782
commit 457f439a9f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1474 additions and 208 deletions

View file

@ -0,0 +1,352 @@
---
id: contributing_plastic
title: Setup for Plastic SCM testing
sidebar_label: Plastic SCM testing
---
When changig the `segment_plastic.go` file, you may need to test your changes against an actual instance of
[Plastic SCM][plastic]. This doc should bring you up to speed with Plastic SCM.
In the [contributing doc][contributing] there is a section about [dev containers & codespaces][devcontainer].
You can setup Plastic SCM inside these as well.
## Sever Setup
Here you can find the [official setup instructions][setup-instructions]. I'll describe it in short:
### Installation on Debian or in dev-container
First add the repo:
```bash
sudo apt-get update
sudo apt-get install -y apt-transport-https
echo "deb https://www.plasticscm.com/plasticrepo/stable/debian/ ./" | sudo tee /etc/apt/sources.list.d/plasticscm-stable.list
wget https://www.plasticscm.com/plasticrepo/stable/debian/Release.key -O - | sudo apt-key add -
sudo apt-get update
```
Then install the server: *this might throw an error at the end of the setup **see below***
```bash
sudo apt-get install plasticscm-server-core
```
This might show an error while configuring the installed package. In that case the server was nod registered as a service.
**Ignore it!**
### Server configuration
Configuring the server is done via:
```bash
cd /opt/plasticscm5/server/
sudo ./plasticd configure
```
You are asked 5 questions. Choose these options:
1. **1**: English
2. 8087 (default port, **just hit return**)
3. 8088 (default ssl port, **just hit return**)
4. **1**: NameWorkingMode (use local users and groups)
5. skip license token (**just hit return**)
**Concrats!** Your server is configured. You can find out more in the [official configuration instructions][server-config].
### Run Server
If your server installed without an error, it was correctly registered as a server and can be started via:
```bash
sudo service plasticd start
```
If not, you need to start it manually (for example inside the dev-container):
```bash
cd /opt/plasticscm5/server/
sudo ./plasticd start
```
This will lock the current shell until the server process finishes. You might need to open another terminal to continue ;)
Your Plastic SCM server should be started now.
## Client Setup
Plastic SCM comes, much like git, with a CLI (+ client UI \[optional\])
### Installation on Debian or in dev-container
These are the steps to install the **Plastic SCM CLI** on Debian or in the dev-container:
```bash
sudo apt-get install plasticscm-client-core
```
### Client configuration
To connect the client to the server and setup an account run:
```bash
clconfigureclient
```
You are asked a few questions. Choose these options:
1. **1**: English
2. localhost (**just hit return**)
3. default port 8087 (**just hit return**)
4. No SSL (**just hit return**)
5. No Proxy (**just hit return**)
**Concrats!** Your client should now be connected to your server.
You can test if it worked and display some license info via:
```bash
cm li
```
## Testing stuff
Now to the fun part! The server is automatically setup to host a `default` repo with the branch `/main`.
The Plastic SCM CLI command is: `cm`
If you ever wonder what you can do with it call:
```bash
cm showcommands --all`
```
### Creating a local workspace
You need a local workspace to work with plastic:
```bash
cd ~
mkdir dev
cd dev
cm wk create workspace workspace rep:default
cd workspace
cm status
```
### Adding files
Start by creating local, private files
```bash
echo "test" > myfile.txt
cm status --all
```
Add the file to your local changes
```bash
cm add myfile.txt
cm status
```
**Test hint:** Both `Private` and `Added` files should be counted towards the `Added` property of the `plastic` segment.
### Commiting changes
After locally adding, changing, moving or delting files you want to commit them to create a new changeset.
Run this command to commit all local changes:
```bash
cm status | cm ci . -c "my first commit"
```
### Unding local changes
Just in case you don't want or can commit your local changes, there is an undo command.
This will undo all local changes:
```bash
cm status | cm undo .
```
### Changing, moving or deleting files
All these actions are done on the file level. You can run `cm status` to see your actions beeing tracked by plastic.
Use the commit method described above to commit your changes.
**Test hint:** All these changes should be counted by the designated property (`Modified`, `Moved`, `Deleted`)
of the `plastic` segment.
### Branching
Above the basics of handling the Plastic SCM client are described.
But you would want to dive deeper and use branches or labels and merge them.
#### Create a new branch
To create a new branch based on the latest changeset on branch `/main` call
```bash
cm br /main/new-branch
```
Hint: To list all branches use
```bash
cm find branches
```
#### Set a label to the current changeset
Your workspace will always reflect one specific changeset (see `cm status`). You can set a label on that changeset for
fast navigation or documentation purposes
```bash
cm label mk "BL0001"
```
Hint: To list all labels use
```bash
cm find labels
```
#### Switch your local workspace to a branch
To switch to a branch use
```bash
cm switch /main/new-branch
cm status
```
**Test Hint:** the branch name should be reflected in the `Selector` property of the `plastic` segment
#### Switch to a changeset
Each commit gets a uniqe changeset number. You can switch to these via
```bash
cm switch cs:1
```
**Test Hint:** the changeset should be reflected in the `Selector` property of the `plastic` segment
#### Switch to a label
You can also switch to a label via
```bash
cm switch BL00001
```
**Test Hint:** the label should be reflected in the `Selector` property of the `plastic` segment
#### Merge a branch
To merge a branch you have to switch to the *destionation* branch of the merge. After that you can merge another branch via
```bash
cm switch /main
cm merge /main/new-branch --merge
cm status
```
Hint: This will only prepare the merge locally. You will have to commit the changes to complete the merge!
**Test Hint:** A pending merge should be reflected in the `MergePending` property of the `plastic` segment
#### Cherry-pick merge
While the merge above will merge all changes from a branch (and his parents), there is a cherry-pick merge,
which will merge only the changes of one single changeset
```bach
cm merge cs:8 --merge --cherrypicking
```
Hint: This will only prepare the merge locally. You will have to commit the changes to complete the merge!
**Test Hint:** A pending cherry-pick merge should be reflected in the `MergePending` property of the `plastic` segment
#### Merge conflicts
There are multiple causes for conflicts while merging
##### Evil Twin
this happens when a merge is performed where two files with the same name were added on both the source and desitation branch.
```bash
cm br mk /main/sub-branch
cm switch /main/sub-branch
echo "1" > twin.txt
cm add twin.txt
cm ci twin.txt
cm switch /main
echo "2" > twin.txt
cm add twin.txt
cm ci twin.txt
cm merge /main/sub-branch --merge
```
Hint: this will prompt you to directly resolve the conflict
##### Changed on both sides
this happens when a merge is performed where a file was changed on both sides: source and destination
```bash
cm switch /main
echo "base" > file.txt
cm add file.txt
cm ci file.txt
cm br mk /main/test
echo "on main" > file.txt
cm ci file.txt
cm switch /main/test
echo "on test" > file.txt
cm ci file.txt
cm switch /main
cm merge /main/test --merge
```
Hint: this will try to open `gtkmergetool` which will fail inside the dev-container!
##### changed vs. deleted file
this happens when a merge is performed where a file was modified on one side and deleted on the other side of the merge
```bash
cm switch /main
echo "base" > deleteme.txt
cm add deleteme.txt
cm ci deleteme.txt
cm br mk /main/del
rm deleteme.txt
cm ci --all
cm switch /main/del
echo "on del" > deleteme.txt
cm ci deleteme.txt
cm switch /main
cm merge /main/del --merge
```
Hint: This will prompt you to directly resolve the merge conflict
[plastic]: https://www.plasticscm.com/
[setup-instructions]: https://www.plasticscm.com/documentation/administration/plastic-scm-version-control-administrator-guide#Chapter3:PlasticSCMinstallation
[server-config]: https://www.plasticscm.com/documentation/administration/plastic-scm-version-control-administrator-guide#Serverconfiguration
[contributing]: https://github.com/JanDeDobbeleer/oh-my-posh/blob/main/CONTRIBUTING.md
[devcontainer]: https://github.com/JanDeDobbeleer/oh-my-posh/blob/main/CONTRIBUTING.md#codespaces--devcontainer-development-environment

View file

@ -0,0 +1,97 @@
---
id: plastic
title: Plastic SCM
sidebar_label: Plastic SCM
---
## What
Display Plastic SCM information when in a plastic repository. Also works for subfolders.
For maximum compatibility, make sure your `cm` executable is up-to-date
(when branch or status information is incorrect for example).
Local changes can also be displayed which uses the following syntax (see `.Status` property below):
- `+` added
- `~` modified
- `-` deleted
- `>` moved
- `x` unmerged
## Sample Configuration
```json
{
"type": "plastic",
"style": "powerline",
"powerline_symbol": "\uE0B0",
"foreground": "#193549",
"background": "#ffeb3b",
"background_templates": [
"{{ if .MergePending }}#006060{{ end }}",
"{{ if .Changed }}#FF9248{{ end }}",
"{{ if and .Changed .Behind }}#ff4500{{ end }}",
"{{ if .Behind }}#B388FF{{ end }}"
],
"properties": {
"fetch_status": true,
"branch_max_length": 25,
"truncate_symbol": "\u2026",
"template": "{{ .Selector }}{{ if .Status.Changed }} \uF044 {{ end }}{{ .Status.String }}"
}
}
```
## Plastic SCM Icon
If you want to use the icon of Plastic SCM in the segment, then please help me push the icon in this [issue][fa-issue]
by leaving a like!
![icon](https://www.plasticscm.com/images/icon-logo-plasticscm.svg)
## Properties
- template: `string` - A go [text/template][go-text-template] template extended with [sprig][sprig] utilizing the
properties below - defaults to empty.
### Fetching information
As doing multiple `cm` calls can slow down the prompt experience, we do not fetch information by default.
You can set the following property to `true` to enable fetching additional information (and populate the template).
- fetch_status: `boolean` - fetch the local changes - defaults to `false`
### Icons
#### Branch
- branch_icon: `string` - the icon to use in front of the git branch name - defaults to `\uE0A0 `
- full_branch_path: `bool` - display the full branch path: */main/fix-001* instead of *fix-001* - defaults to `true`
- branch_max_length: `int` - the max length for the displayed branch name where `0` implies full length - defaults to `0`
- truncate_symbol: `string` - the icon to display when a branch name is truncated - defaults to empty
#### Selector
- commit_icon: `string` - icon/text to display before the commit context (detached HEAD) - defaults to `\uF417`
- tag_icon: `string` - icon/text to display before the tag context - defaults to `\uF412`
## Template Properties
- `.Selector`: `string` - the current selector context (branch/changeset/label)
- `.Behind`: `bool` - the current workspace is behind and changes are incoming
- `.Status`: `PlasticStatus` - changes in the workspace (see below)
- `.MergePending`: `bool` - if a merge is pending and needs to be commited
(kown issue: when no file is left after a *Change/Delete conflict* merge, the `MergePending` property is not set)
### PlasticStatus
- `.Unmerged`: `int` - number of unmerged changes
- `.Deleted`: `int` - number of deleted changes
- `.Added`: `int` - number of added changes
- `.Modified`: `int` - number of modified changes
- `.Moved`: `int` - number of moved changes
- `.Changed`: `boolean` - if the status contains changes or not
- `.String`: `string` - a string representation of the changes above
[go-text-template]: https://golang.org/pkg/text/template/
[sprig]: https://masterminds.github.io/sprig/
[fa-issue]: https://github.com/FortAwesome/Font-Awesome/issues/18504

View file

@ -61,6 +61,7 @@ module.exports = {
"owm",
"path",
"php",
"plastic",
"python",
"root",
"ruby",
@ -85,6 +86,7 @@ module.exports = {
"contributing_started",
"contributing_segment",
"contributing_git",
"contributing_plastic",
],
},
"themes",

81
src/scm.go Normal file
View file

@ -0,0 +1,81 @@
package main
import (
"fmt"
"strings"
)
// ScmStatus represents part of the status of a repository
type ScmStatus struct {
Unmerged int
Deleted int
Added int
Modified int
Moved int
}
func (s *ScmStatus) Changed() bool {
return s.Added > 0 || s.Deleted > 0 || s.Modified > 0 || s.Unmerged > 0 || s.Moved > 0
}
func (s *ScmStatus) String() string {
var status string
stringIfValue := func(value int, prefix string) string {
if value > 0 {
return fmt.Sprintf(" %s%d", prefix, value)
}
return ""
}
status += stringIfValue(s.Added, "+")
status += stringIfValue(s.Modified, "~")
status += stringIfValue(s.Deleted, "-")
status += stringIfValue(s.Moved, ">")
status += stringIfValue(s.Unmerged, "x")
return strings.TrimSpace(status)
}
type scm struct {
props properties
env environmentInfo
}
const (
// BranchMaxLength truncates the length of the branch name
BranchMaxLength Property = "branch_max_length"
// TruncateSymbol appends the set symbol to a truncated branch name
TruncateSymbol Property = "truncate_symbol"
// FullBranchPath displays the full path of a branch
FullBranchPath Property = "full_branch_path"
)
func (s *scm) init(props properties, env environmentInfo) {
s.props = props
s.env = env
}
func (s *scm) truncateBranch(branch string) string {
fullBranchPath := s.props.getBool(FullBranchPath, true)
maxLength := s.props.getInt(BranchMaxLength, 0)
if !fullBranchPath && len(branch) > 0 {
index := strings.LastIndex(branch, "/")
branch = branch[index+1:]
}
if maxLength == 0 || len(branch) <= maxLength {
return branch
}
symbol := s.props.getString(TruncateSymbol, "")
return branch[0:maxLength] + symbol
}
func (s *scm) shouldIgnoreRootRepository(rootDir string) bool {
value, ok := s.props[ExcludeFolders]
if !ok {
return false
}
excludedFolders := parseStringArray(value)
return dirMatchesOneOf(s.env, rootDir, excludedFolders)
}
func (s *scm) getFileContents(folder, file string) string {
return strings.Trim(s.env.getFileContent(folder+"/"+file), " \r\n")
}

186
src/scm_test.go Normal file
View file

@ -0,0 +1,186 @@
package main
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestScmStatusChanged(t *testing.T) {
cases := []struct {
Case string
Expected bool
Status ScmStatus
}{
{
Case: "No changes",
Expected: false,
Status: ScmStatus{},
},
{
Case: "Added",
Expected: true,
Status: ScmStatus{
Added: 1,
},
},
{
Case: "Moved",
Expected: true,
Status: ScmStatus{
Moved: 1,
},
},
{
Case: "Modified",
Expected: true,
Status: ScmStatus{
Modified: 1,
},
},
{
Case: "Deleted",
Expected: true,
Status: ScmStatus{
Deleted: 1,
},
},
{
Case: "Unmerged",
Expected: true,
Status: ScmStatus{
Unmerged: 1,
},
},
}
for _, tc := range cases {
assert.Equal(t, tc.Expected, tc.Status.Changed(), tc.Case)
}
}
func TestScmStatusUnmerged(t *testing.T) {
expected := "x1"
status := &ScmStatus{
Unmerged: 1,
}
assert.Equal(t, expected, status.String())
}
func TestScmStatusUnmergedModified(t *testing.T) {
expected := "~3 x1"
status := &ScmStatus{
Unmerged: 1,
Modified: 3,
}
assert.Equal(t, expected, status.String())
}
func TestScmStatusEmpty(t *testing.T) {
expected := ""
status := &ScmStatus{}
assert.Equal(t, expected, status.String())
}
func TestTruncateBranch(t *testing.T) {
cases := []struct {
Case string
Expected string
Branch string
FullBranch bool
MaxLength interface{}
}{
{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 {
var props properties = map[Property]interface{}{
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 interface{}
TruncateSymbol interface{}
}{
{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 {
var props properties = map[Property]interface{}{
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 TestScmShouldIgnoreRootRepository(t *testing.T) {
cases := []struct {
Case string
Dir string
Expected bool
}{
{Case: "inside excluded", Dir: "/home/bill/repo"},
{Case: "oustide excluded", Dir: "/home/melinda"},
{Case: "excluded exact match", Dir: "/home/gates", Expected: true},
{Case: "excluded inside match", Dir: "/home/gates/bill", Expected: true},
}
for _, tc := range cases {
var props properties = map[Property]interface{}{
ExcludeFolders: []string{
"/home/bill",
"/home/gates.*",
},
}
env := new(MockedEnvironment)
env.On("homeDir", nil).Return("/home/bill")
env.On("getRuntimeGOOS", nil).Return(windowsPlatform)
s := &scm{
props: props,
env: env,
}
got := s.shouldIgnoreRootRepository(tc.Dir)
assert.Equal(t, tc.Expected, got, tc.Case)
}
}

View file

@ -57,6 +57,8 @@ const (
Path SegmentType = "path"
// Git represents the git status and information
Git SegmentType = "git"
// Plastic represents the plastic scm status and information
Plastic SegmentType = "plastic"
// Exit writes the last exit code
Exit SegmentType = "exit"
// Python writes the virtual env name
@ -230,6 +232,7 @@ func (segment *Segment) mapSegmentWithWriter(env environmentInfo) error {
Session: &session{},
Path: &path{},
Git: &git{},
Plastic: &plastic{},
Exit: &exit{},
Python: &python{},
Root: &root{},

View file

@ -13,7 +13,9 @@ import (
func TestGetStatusDetailStringDefault(t *testing.T) {
expected := "icon +1"
status := &GitStatus{
ScmStatus: ScmStatus{
Added: 1,
},
}
g := &git{}
assert.Equal(t, expected, g.getStatusDetailString(status, WorkingColor, LocalWorkingIcon, "icon"))
@ -22,13 +24,17 @@ func TestGetStatusDetailStringDefault(t *testing.T) {
func TestGetStatusDetailStringDefaultColorOverride(t *testing.T) {
expected := "<#123456>icon +1</>"
status := &GitStatus{
ScmStatus: ScmStatus{
Added: 1,
},
}
var props properties = map[Property]interface{}{
WorkingColor: "#123456",
}
g := &git{
scm: scm{
props: props,
},
}
assert.Equal(t, expected, g.getStatusDetailString(status, WorkingColor, LocalWorkingIcon, "icon"))
}
@ -36,14 +42,18 @@ func TestGetStatusDetailStringDefaultColorOverride(t *testing.T) {
func TestGetStatusDetailStringDefaultColorOverrideAndIconColorOverride(t *testing.T) {
expected := "<#789123>work</> <#123456>+1</>"
status := &GitStatus{
ScmStatus: ScmStatus{
Added: 1,
},
}
var props properties = map[Property]interface{}{
WorkingColor: "#123456",
LocalWorkingIcon: "<#789123>work</>",
}
g := &git{
scm: scm{
props: props,
},
}
assert.Equal(t, expected, g.getStatusDetailString(status, WorkingColor, LocalWorkingIcon, "icon"))
}
@ -51,14 +61,18 @@ func TestGetStatusDetailStringDefaultColorOverrideAndIconColorOverride(t *testin
func TestGetStatusDetailStringDefaultColorOverrideNoIconColorOverride(t *testing.T) {
expected := "<#123456>work +1</>"
status := &GitStatus{
ScmStatus: ScmStatus{
Added: 1,
},
}
var props properties = map[Property]interface{}{
WorkingColor: "#123456",
LocalWorkingIcon: "work",
}
g := &git{
scm: scm{
props: props,
},
}
assert.Equal(t, expected, g.getStatusDetailString(status, WorkingColor, LocalWorkingIcon, "icon"))
}
@ -66,13 +80,17 @@ func TestGetStatusDetailStringDefaultColorOverrideNoIconColorOverride(t *testing
func TestGetStatusDetailStringNoStatus(t *testing.T) {
expected := "icon"
status := &GitStatus{
ScmStatus: ScmStatus{
Added: 1,
},
}
var props properties = map[Property]interface{}{
DisplayStatusDetail: false,
}
g := &git{
scm: scm{
props: props,
},
}
assert.Equal(t, expected, g.getStatusDetailString(status, WorkingColor, LocalWorkingIcon, "icon"))
}
@ -80,14 +98,18 @@ func TestGetStatusDetailStringNoStatus(t *testing.T) {
func TestGetStatusDetailStringNoStatusColorOverride(t *testing.T) {
expected := "<#123456>icon</>"
status := &GitStatus{
ScmStatus: ScmStatus{
Added: 1,
},
}
var props properties = map[Property]interface{}{
DisplayStatusDetail: false,
WorkingColor: "#123456",
}
g := &git{
scm: scm{
props: props,
},
}
assert.Equal(t, expected, g.getStatusDetailString(status, WorkingColor, LocalWorkingIcon, "icon"))
}
@ -98,10 +120,14 @@ func TestGetStatusColorLocalChangesStaging(t *testing.T) {
LocalChangesColor: expected,
}
g := &git{
scm: scm{
props: props,
},
Staging: &GitStatus{
ScmStatus: ScmStatus{
Modified: 1,
},
},
Working: &GitStatus{},
}
assert.Equal(t, expected, g.getStatusColor("#fg1111"))
@ -113,11 +139,15 @@ func TestGetStatusColorLocalChangesWorking(t *testing.T) {
LocalChangesColor: expected,
}
g := &git{
scm: scm{
props: props,
},
Staging: &GitStatus{},
Working: &GitStatus{
ScmStatus: ScmStatus{
Modified: 1,
},
},
}
assert.Equal(t, expected, g.getStatusColor("#fg1111"))
}
@ -128,7 +158,9 @@ func TestGetStatusColorAheadAndBehind(t *testing.T) {
AheadAndBehindColor: expected,
}
g := &git{
scm: scm{
props: props,
},
Staging: &GitStatus{},
Working: &GitStatus{},
Ahead: 1,
@ -143,7 +175,9 @@ func TestGetStatusColorAhead(t *testing.T) {
AheadColor: expected,
}
g := &git{
scm: scm{
props: props,
},
Staging: &GitStatus{},
Working: &GitStatus{},
Ahead: 1,
@ -158,7 +192,9 @@ func TestGetStatusColorBehind(t *testing.T) {
BehindColor: expected,
}
g := &git{
scm: scm{
props: props,
},
Staging: &GitStatus{},
Working: &GitStatus{},
Ahead: 0,
@ -173,7 +209,9 @@ func TestGetStatusColorDefault(t *testing.T) {
BehindColor: changesColor,
}
g := &git{
scm: scm{
props: props,
},
Staging: &GitStatus{},
Working: &GitStatus{},
Ahead: 0,
@ -189,10 +227,14 @@ func TestSetStatusColorForeground(t *testing.T) {
ColorBackground: false,
}
g := &git{
scm: scm{
props: props,
},
Staging: &GitStatus{
ScmStatus: ScmStatus{
Added: 1,
},
},
Working: &GitStatus{},
}
g.SetStatusColor()
@ -206,11 +248,15 @@ func TestSetStatusColorBackground(t *testing.T) {
ColorBackground: true,
}
g := &git{
scm: scm{
props: props,
},
Staging: &GitStatus{},
Working: &GitStatus{
ScmStatus: ScmStatus{
Modified: 1,
},
},
}
g.SetStatusColor()
assert.Equal(t, expected, g.props[BackgroundOverride])
@ -234,13 +280,15 @@ func TestStatusColorsWithoutDisplayStatus(t *testing.T) {
env.mockGitCommand("", "describe", "--tags", "--exact-match")
env.mockGitCommand(status, "status", "-unormal", "--branch", "--porcelain=2")
g := &git{
scm: scm{
env: env,
gitWorkingFolder: "",
props: map[Property]interface{}{
DisplayStatus: false,
StatusColorsEnabled: true,
LocalChangesColor: expected,
},
},
gitWorkingFolder: "",
}
g.Working = &GitStatus{}
g.Staging = &GitStatus{}

View file

@ -10,10 +10,7 @@ import (
// GitStatus represents part of the status of a git repository
type GitStatus struct {
Unmerged int
Deleted int
Added int
Modified int
ScmStatus
}
func (s *GitStatus) add(code string) {
@ -31,28 +28,8 @@ func (s *GitStatus) add(code string) {
}
}
func (s *GitStatus) Changed() bool {
return s.Added > 0 || s.Deleted > 0 || s.Modified > 0 || s.Unmerged > 0
}
func (s *GitStatus) String() string {
var status string
stringIfValue := func(value int, prefix string) string {
if value > 0 {
return fmt.Sprintf(" %s%d", prefix, value)
}
return ""
}
status += stringIfValue(s.Added, "+")
status += stringIfValue(s.Modified, "~")
status += stringIfValue(s.Deleted, "-")
status += stringIfValue(s.Unmerged, "x")
return strings.TrimSpace(status)
}
type git struct {
props properties
env environmentInfo
scm
Working *GitStatus
Staging *GitStatus
@ -85,10 +62,6 @@ const (
// FetchUpstreamIcon fetches the upstream icon
FetchUpstreamIcon Property = "fetch_upstream_icon"
// BranchMaxLength truncates the length of the branch name
BranchMaxLength Property = "branch_max_length"
// TruncateSymbol appends the set symbol to a truncated branch name
TruncateSymbol Property = "truncate_symbol"
// BranchIcon the icon to use as branch indicator
BranchIcon Property = "branch_icon"
// BranchIdenticalIcon the icon to display when the remote and local branch are identical
@ -163,15 +136,6 @@ func (g *git) enabled() bool {
return false
}
func (g *git) shouldIgnoreRootRepository(rootDir string) bool {
value, ok := g.props[ExcludeFolders]
if !ok {
return false
}
excludedFolders := parseStringArray(value)
return dirMatchesOneOf(g.env, rootDir, excludedFolders)
}
func (g *git) string() string {
statusColorsEnabled := g.props.getBool(StatusColorsEnabled, false)
displayStatus := g.props.getOneOfBool(FetchStatus, DisplayStatus, false)
@ -218,11 +182,6 @@ func (g *git) templateString(segmentTemplate string) string {
return text
}
func (g *git) init(props properties, env environmentInfo) {
g.props = props
g.env = env
}
func (g *git) setBranchStatus() {
getBranchStatus := func() string {
if g.Ahead > 0 && g.Behind > 0 {
@ -352,7 +311,7 @@ func (g *git) setGitHEADContext() {
getPrettyNameOrigin := func(file string) string {
var origin string
head := g.getGitFileContents(g.gitWorkingFolder, file)
head := g.getFileContents(g.gitWorkingFolder, file)
if head == "detached HEAD" {
origin = formatDetached()
} else {
@ -366,16 +325,16 @@ func (g *git) setGitHEADContext() {
origin := getPrettyNameOrigin("rebase-merge/head-name")
onto := g.getGitRefFileSymbolicName("rebase-merge/onto")
onto = g.formatHEAD(onto)
step := g.getGitFileContents(g.gitWorkingFolder, "rebase-merge/msgnum")
total := g.getGitFileContents(g.gitWorkingFolder, "rebase-merge/end")
step := g.getFileContents(g.gitWorkingFolder, "rebase-merge/msgnum")
total := g.getFileContents(g.gitWorkingFolder, "rebase-merge/end")
icon := g.props.getString(RebaseIcon, "\uE728 ")
g.HEAD = fmt.Sprintf("%s%s onto %s%s (%s/%s) at %s", icon, origin, branchIcon, onto, step, total, g.HEAD)
return
}
if g.env.hasFolder(g.gitWorkingFolder + "/rebase-apply") {
origin := getPrettyNameOrigin("rebase-apply/head-name")
step := g.getGitFileContents(g.gitWorkingFolder, "rebase-apply/next")
total := g.getGitFileContents(g.gitWorkingFolder, "rebase-apply/last")
step := g.getFileContents(g.gitWorkingFolder, "rebase-apply/next")
total := g.getFileContents(g.gitWorkingFolder, "rebase-apply/last")
icon := g.props.getString(RebaseIcon, "\uE728 ")
g.HEAD = fmt.Sprintf("%s%s (%s/%s) at %s", icon, origin, step, total, g.HEAD)
return
@ -384,7 +343,7 @@ func (g *git) setGitHEADContext() {
commitIcon := g.props.getString(CommitIcon, "\uF417")
if g.hasGitFile("MERGE_MSG") {
icon := g.props.getString(MergeIcon, "\uE727 ")
mergeContext := g.getGitFileContents(g.gitWorkingFolder, "MERGE_MSG")
mergeContext := g.getFileContents(g.gitWorkingFolder, "MERGE_MSG")
matches := findNamedRegexMatch(`Merge (remote-tracking )?(?P<type>branch|commit|tag) '(?P<theirs>.*)'`, mergeContext)
// head := g.getGitRefFileSymbolicName("ORIG_HEAD")
if matches != nil && matches["theirs"] != "" {
@ -410,19 +369,19 @@ func (g *git) setGitHEADContext() {
// reverts then CHERRY_PICK_HEAD/REVERT_HEAD will not exist so we have to read
// the todo file.
if g.hasGitFile("CHERRY_PICK_HEAD") {
sha := g.getGitFileContents(g.gitWorkingFolder, "CHERRY_PICK_HEAD")
sha := g.getFileContents(g.gitWorkingFolder, "CHERRY_PICK_HEAD")
cherry := g.props.getString(CherryPickIcon, "\uE29B ")
g.HEAD = fmt.Sprintf("%s%s%s onto %s", cherry, commitIcon, g.formatSHA(sha), formatDetached())
return
}
if g.hasGitFile("REVERT_HEAD") {
sha := g.getGitFileContents(g.gitWorkingFolder, "REVERT_HEAD")
sha := g.getFileContents(g.gitWorkingFolder, "REVERT_HEAD")
revert := g.props.getString(RevertIcon, "\uF0E2 ")
g.HEAD = fmt.Sprintf("%s%s%s onto %s", revert, commitIcon, g.formatSHA(sha), formatDetached())
return
}
if g.hasGitFile("sequencer/todo") {
todo := g.getGitFileContents(g.gitWorkingFolder, "sequencer/todo")
todo := g.getFileContents(g.gitWorkingFolder, "sequencer/todo")
matches := findNamedRegexMatch(`^(?P<action>p|pick|revert)\s+(?P<sha>\S+)`, todo)
if matches != nil && matches["sha"] != "" {
action := matches["action"]
@ -462,19 +421,15 @@ func (g *git) hasGitFile(file string) bool {
return g.env.hasFilesInDir(g.gitWorkingFolder, file)
}
func (g *git) getGitFileContents(folder, file string) string {
return strings.Trim(g.env.getFileContent(folder+"/"+file), " \r\n")
}
func (g *git) getGitRefFileSymbolicName(refFile string) string {
ref := g.getGitFileContents(g.gitWorkingFolder, refFile)
ref := g.getFileContents(g.gitWorkingFolder, refFile)
return g.getGitCommandOutput("name-rev", "--name-only", "--exclude=tags/*", ref)
}
func (g *git) setPrettyHEADName() {
// we didn't fetch status, fallback to parsing the HEAD file
if len(g.Hash) == 0 {
HEADRef := g.getGitFileContents(g.gitWorkingFolder, "HEAD")
HEADRef := g.getFileContents(g.gitWorkingFolder, "HEAD")
if strings.HasPrefix(HEADRef, BRANCHPREFIX) {
branchName := strings.TrimPrefix(HEADRef, BRANCHPREFIX)
g.HEAD = fmt.Sprintf("%s%s", g.props.getString(BranchIcon, "\uE0A0"), g.formatHEAD(branchName))
@ -500,7 +455,7 @@ func (g *git) setPrettyHEADName() {
}
func (g *git) getStashContext() int {
stashContent := g.getGitFileContents(g.gitRootFolder, "logs/refs/stash")
stashContent := g.getFileContents(g.gitRootFolder, "logs/refs/stash")
if stashContent == "" {
return 0
}

View file

@ -19,7 +19,9 @@ func TestEnabledGitNotFound(t *testing.T) {
env.On("getRuntimeGOOS", nil).Return("")
env.On("isWsl", nil).Return(false)
g := &git{
scm: scm{
env: env,
},
}
assert.False(t, g.enabled())
}
@ -36,7 +38,9 @@ func TestEnabledInWorkingDirectory(t *testing.T) {
}
env.On("hasParentFilePath", ".git").Return(fileInfo, nil)
g := &git{
scm: scm{
env: env,
},
}
assert.True(t, g.enabled())
assert.Equal(t, fileInfo.path, g.gitWorkingFolder)
@ -56,7 +60,9 @@ func TestEnabledInWorkingTree(t *testing.T) {
env.On("getFileContent", "/dev/folder_worktree/.git").Return("gitdir: /dev/real_folder/.git/worktrees/folder_worktree")
env.On("getFileContent", "/dev/real_folder/.git/worktrees/folder_worktree/gitdir").Return("/dev/folder_worktree.git\n")
g := &git{
scm: scm{
env: env,
},
}
assert.True(t, g.enabled())
assert.Equal(t, "/dev/real_folder/.git/worktrees/folder_worktree", g.gitWorkingFolder)
@ -72,7 +78,9 @@ func TestGetGitOutputForCommand(t *testing.T) {
env.On("runCommand", "git", append(args, commandArgs...)).Return(want, nil)
env.On("getRuntimeGOOS", nil).Return("unix")
g := &git{
scm: scm{
env: env,
},
}
got := g.getGitCommandOutput(commandArgs...)
assert.Equal(t, want, got)
@ -217,6 +225,7 @@ func TestSetGitHEADContextClean(t *testing.T) {
env.On("getFileContent", "/sequencer/todo").Return(tc.Theirs)
g := &git{
scm: scm{
env: env,
props: map[Property]interface{}{
BranchIcon: "branch ",
@ -227,6 +236,7 @@ func TestSetGitHEADContextClean(t *testing.T) {
TagIcon: "tag ",
RevertIcon: "revert ",
},
},
Hash: "1234567",
Ref: tc.Ref,
}
@ -257,12 +267,14 @@ func TestSetPrettyHEADName(t *testing.T) {
env.On("isWsl", nil).Return(false)
env.mockGitCommand(tc.Tag, "describe", "--tags", "--exact-match")
g := &git{
scm: scm{
env: env,
props: map[Property]interface{}{
BranchIcon: "branch ",
CommitIcon: "commit ",
TagIcon: "tag ",
},
},
Hash: tc.Hash,
}
g.setPrettyHEADName()
@ -297,8 +309,8 @@ func TestSetGitStatus(t *testing.T) {
1 .U N...
1 A. N...
`,
ExpectedWorking: &GitStatus{Modified: 4, Added: 2, Deleted: 1, Unmerged: 1},
ExpectedStaging: &GitStatus{Added: 1},
ExpectedWorking: &GitStatus{ScmStatus: ScmStatus{Modified: 4, Added: 2, Deleted: 1, Unmerged: 1}},
ExpectedStaging: &GitStatus{ScmStatus: ScmStatus{Added: 1}},
ExpectedHash: "1234567",
ExpectedRef: "rework-git-status",
},
@ -318,8 +330,8 @@ func TestSetGitStatus(t *testing.T) {
1 .U N...
1 A. N...
`,
ExpectedWorking: &GitStatus{Modified: 4, Added: 2, Deleted: 1, Unmerged: 1},
ExpectedStaging: &GitStatus{Added: 1},
ExpectedWorking: &GitStatus{ScmStatus: ScmStatus{Modified: 4, Added: 2, Deleted: 1, Unmerged: 1}},
ExpectedStaging: &GitStatus{ScmStatus: ScmStatus{Added: 1}},
ExpectedUpstream: "origin/rework-git-status",
ExpectedHash: "1234567",
ExpectedRef: "rework-git-status",
@ -356,7 +368,9 @@ func TestSetGitStatus(t *testing.T) {
env.On("isWsl", nil).Return(false)
env.mockGitCommand(strings.ReplaceAll(tc.Output, "\t", ""), "status", "-unormal", "--branch", "--porcelain=2")
g := &git{
scm: scm{
env: env,
},
}
if tc.ExpectedWorking == nil {
tc.ExpectedWorking = &GitStatus{}
@ -388,7 +402,9 @@ func TestGetStashContextZeroEntries(t *testing.T) {
env := new(MockedEnvironment)
env.On("getFileContent", "/logs/refs/stash").Return(tc.StashContent)
g := &git{
scm: scm{
env: env,
},
gitWorkingFolder: "",
}
got := g.getStashContext()
@ -396,29 +412,6 @@ func TestGetStashContextZeroEntries(t *testing.T) {
}
}
func TestGitStatusUnmerged(t *testing.T) {
expected := "x1"
status := &GitStatus{
Unmerged: 1,
}
assert.Equal(t, expected, status.String())
}
func TestGitStatusUnmergedModified(t *testing.T) {
expected := "~3 x1"
status := &GitStatus{
Unmerged: 1,
Modified: 3,
}
assert.Equal(t, expected, status.String())
}
func TestGitStatusEmpty(t *testing.T) {
expected := ""
status := &GitStatus{}
assert.Equal(t, expected, status.String())
}
func TestGitUpstream(t *testing.T) {
cases := []struct {
Case string
@ -446,8 +439,10 @@ func TestGitUpstream(t *testing.T) {
GitIcon: "G",
}
g := &git{
scm: scm{
env: env,
props: props,
},
Upstream: "origin/main",
}
upstreamIcon := g.getUpstreamIcon()
@ -479,7 +474,9 @@ func TestGetBranchStatus(t *testing.T) {
BranchGoneIcon: "gone",
}
g := &git{
scm: scm{
props: props,
},
Ahead: tc.Ahead,
Behind: tc.Behind,
Upstream: tc.Upstream,
@ -512,66 +509,16 @@ func TestShouldIgnoreRootRepository(t *testing.T) {
env.On("homeDir", nil).Return("/home/bill")
env.On("getRuntimeGOOS", nil).Return(windowsPlatform)
git := &git{
scm: scm{
props: props,
env: env,
},
}
got := git.shouldIgnoreRootRepository(tc.Dir)
assert.Equal(t, tc.Expected, got, tc.Case)
}
}
func TestTruncateBranch(t *testing.T) {
cases := []struct {
Case string
Expected string
Branch string
MaxLength interface{}
}{
{Case: "No limit", Expected: "all-your-base-are-belong-to-us", Branch: "all-your-base-are-belong-to-us"},
{Case: "No limit - larger", Expected: "all-your-base", Branch: "all-your-base-are-belong-to-us", MaxLength: 13.0},
{Case: "No limit - smaller", Expected: "all-your-base", Branch: "all-your-base", MaxLength: 13.0},
{Case: "Invalid setting", Expected: "all-your-base", Branch: "all-your-base", MaxLength: "burp"},
{Case: "Lower than limit", Expected: "all-your-base", Branch: "all-your-base", MaxLength: 20.0},
}
for _, tc := range cases {
var props properties = map[Property]interface{}{
BranchMaxLength: tc.MaxLength,
}
g := &git{
props: props,
}
assert.Equal(t, tc.Expected, g.formatHEAD(tc.Branch), tc.Case)
}
}
func TestTruncateBranchWithSymbol(t *testing.T) {
cases := []struct {
Case string
Expected string
Branch string
MaxLength interface{}
TruncateSymbol interface{}
}{
{Case: "No limit", Expected: "all-your-base-are-belong-to-us", Branch: "all-your-base-are-belong-to-us", TruncateSymbol: "..."},
{Case: "No limit - larger", Expected: "all-your-base...", Branch: "all-your-base-are-belong-to-us", MaxLength: 13.0, TruncateSymbol: "..."},
{Case: "No limit - smaller", Expected: "all-your-base", Branch: "all-your-base", MaxLength: 16.0, TruncateSymbol: "..."},
{Case: "Invalid setting", Expected: "all-your-base", Branch: "all-your-base", MaxLength: "burp", TruncateSymbol: "..."},
{Case: "Lower than limit", Expected: "all-your-base", Branch: "all-your-base", MaxLength: 20.0, TruncateSymbol: "..."},
}
for _, tc := range cases {
var props properties = map[Property]interface{}{
BranchMaxLength: tc.MaxLength,
TruncateSymbol: tc.TruncateSymbol,
}
g := &git{
props: props,
}
assert.Equal(t, tc.Expected, g.formatHEAD(tc.Branch), tc.Case)
}
}
func TestGetGitCommand(t *testing.T) {
cases := []struct {
Case string
@ -599,7 +546,9 @@ func TestGetGitCommand(t *testing.T) {
}
env.On("runCommand", "uname", []string{"-r"}).Return(wslUname, nil)
g := &git{
scm: scm{
env: env,
},
}
assert.Equal(t, tc.Expected, g.getGitCommand(), tc.Case)
}
@ -628,11 +577,13 @@ func TestGitTemplateString(t *testing.T) {
Git: &git{
HEAD: branchName,
Working: &GitStatus{
ScmStatus: ScmStatus{
Added: 2,
Modified: 3,
},
},
},
},
{
Case: "No working area changes",
Expected: branchName,
@ -649,15 +600,19 @@ func TestGitTemplateString(t *testing.T) {
Git: &git{
HEAD: branchName,
Working: &GitStatus{
ScmStatus: ScmStatus{
Added: 2,
Modified: 3,
},
},
Staging: &GitStatus{
ScmStatus: ScmStatus{
Added: 5,
Modified: 1,
},
},
},
},
{
Case: "Working and staging area changes with separator",
Expected: "main \uF046 +5 ~1 | \uF044 +2 ~3",
@ -665,15 +620,19 @@ func TestGitTemplateString(t *testing.T) {
Git: &git{
HEAD: branchName,
Working: &GitStatus{
ScmStatus: ScmStatus{
Added: 2,
Modified: 3,
},
},
Staging: &GitStatus{
ScmStatus: ScmStatus{
Added: 5,
Modified: 1,
},
},
},
},
{
Case: "Working and staging area changes with separator and stash count",
Expected: "main \uF046 +5 ~1 | \uF044 +2 ~3 \uf692 3",
@ -681,13 +640,17 @@ func TestGitTemplateString(t *testing.T) {
Git: &git{
HEAD: branchName,
Working: &GitStatus{
ScmStatus: ScmStatus{
Added: 2,
Modified: 3,
},
},
Staging: &GitStatus{
ScmStatus: ScmStatus{
Added: 5,
Modified: 1,
},
},
StashCount: 3,
},
},

192
src/segment_plastic.go Normal file
View file

@ -0,0 +1,192 @@
package main
import (
"fmt"
"strconv"
"strings"
)
type PlasticStatus struct {
ScmStatus
}
func (s *PlasticStatus) add(code string) {
switch code {
case "LD":
s.Deleted++
case "AD", "PR":
s.Added++
case "LM":
s.Moved++
case "CH", "CO":
s.Modified++
}
}
type plastic struct {
scm
Status *PlasticStatus
Behind bool
Selector string
MergePending bool
plasticWorkspaceFolder string // root folder of workspace
}
func (p *plastic) init(props properties, env environmentInfo) {
p.props = props
p.env = env
}
func (p *plastic) enabled() bool {
if !p.env.hasCommand("cm") {
return false
}
wkdir, err := p.env.hasParentFilePath(".plastic")
if err != nil {
return false
}
if p.shouldIgnoreRootRepository(wkdir.parentFolder) {
return false
}
if wkdir.isDir {
p.plasticWorkspaceFolder = wkdir.parentFolder
return true
}
return false
}
func (p *plastic) string() string {
displayStatus := p.props.getOneOfBool(FetchStatus, DisplayStatus, false)
p.setSelector()
if displayStatus {
p.setPlasticStatus()
}
// use template if available
segmentTemplate := p.props.getString(SegmentTemplate, "")
if len(segmentTemplate) > 0 {
return p.templateString(segmentTemplate)
}
// default: only selector is returned
return p.Selector
}
func (p *plastic) templateString(segmentTemplate string) string {
template := &textTemplate{
Template: segmentTemplate,
Context: p,
Env: p.env,
}
text, err := template.render()
if err != nil {
return err.Error()
}
return text
}
func (p *plastic) setPlasticStatus() {
output := p.getCmCommandOutput("status", "--all", "--machinereadable")
splittedOutput := strings.Split(output, "\n")
// compare to head
currentChangeset := p.parseStatusChangeset(splittedOutput[0])
headChangeset := p.getHeadChangeset()
p.Behind = headChangeset > currentChangeset
// parse file state
p.MergePending = false
p.Status = &PlasticStatus{}
p.parseFilesStatus(splittedOutput)
}
func (p *plastic) parseFilesStatus(output []string) {
if len(output) <= 1 {
return
}
for _, line := range output[1:] {
if len(line) < 3 {
continue
}
if strings.Contains(line, "NO_MERGES") {
p.Status.Unmerged++
continue
}
p.MergePending = p.MergePending || matchString(`(?i)\smerge\s+from\s+[0-9]+\s*$`, line)
code := line[:2]
p.Status.add(code)
}
}
func (p *plastic) parseStringPattern(output, pattern, name string) string {
match := findNamedRegexMatch(pattern, output)
if sValue, ok := match[name]; ok {
return sValue
}
return ""
}
func (p *plastic) parseIntPattern(output, pattern, name string, defValue int) int {
sValue := p.parseStringPattern(output, pattern, name)
if len(sValue) > 0 {
iValue, _ := strconv.Atoi(sValue)
return iValue
}
return defValue
}
func (p *plastic) parseStatusChangeset(status string) int {
return p.parseIntPattern(status, `STATUS\s+(?P<cs>[0-9]+?)\s`, "cs", 0)
}
func (p *plastic) getHeadChangeset() int {
output := p.getCmCommandOutput("status", "--head", "--machinereadable")
return p.parseIntPattern(output, `\bcs:(?P<cs>[0-9]+?)\s`, "cs", 0)
}
func (p *plastic) setSelector() {
var ref string
selector := p.getFileContents(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)
}
p.Selector = fmt.Sprintf("%s%s", p.props.getString(BranchIcon, "\uE0A0"), ref)
}
func (p *plastic) parseChangesetSelector(selector string) string {
return p.parseStringPattern(selector, `\bchangeset "(?P<cs>[0-9]+?)"`, "cs")
}
func (p *plastic) parseLabelSelector(selector string) string {
return p.parseStringPattern(selector, `label "(?P<label>[a-zA-Z0-9\-\_]+?)"`, "label")
}
func (p *plastic) parseBranchSelector(selector string) string {
return p.parseStringPattern(selector, `branch "(?P<branch>[\/a-zA-Z0-9\-\_]+?)"`, "branch")
}
func (p *plastic) getCmCommandOutput(args ...string) string {
val, _ := p.env.runCommand("cm", args...)
return val
}

330
src/segment_plastic_test.go Normal file
View file

@ -0,0 +1,330 @@
package main
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPlasticEnabledNotFound(t *testing.T) {
env := new(MockedEnvironment)
env.On("hasCommand", "cm").Return(false)
env.On("getRuntimeGOOS", nil).Return("")
env.On("isWsl", nil).Return(false)
p := &plastic{
scm: scm{
env: env,
},
}
assert.False(t, p.enabled())
}
func TestPlasticEnabledInWorkspaceDirectory(t *testing.T) {
env := new(MockedEnvironment)
env.On("hasCommand", "cm").Return(true)
env.On("getRuntimeGOOS", nil).Return("")
env.On("isWsl", nil).Return(false)
fileInfo := &fileInfo{
path: "/dir/hello",
parentFolder: "/dir",
isDir: true,
}
env.On("hasParentFilePath", ".plastic").Return(fileInfo, nil)
p := &plastic{
scm: scm{
env: env,
},
}
assert.True(t, p.enabled())
assert.Equal(t, fileInfo.parentFolder, p.plasticWorkspaceFolder)
}
func setupCmStatusEnv(status, headStatus string) *plastic {
env := new(MockedEnvironment)
env.On("runCommand", "cm", []string{"status", "--all", "--machinereadable"}).Return(status, nil)
env.On("runCommand", "cm", []string{"status", "--head", "--machinereadable"}).Return(headStatus, nil)
p := &plastic{
scm: scm{
env: env,
},
}
return p
}
func TestPlasticGetCmOutputForCommand(t *testing.T) {
want := "je suis le output"
p := setupCmStatusEnv(want, "")
got := p.getCmCommandOutput("status", "--all", "--machinereadable")
assert.Equal(t, want, got)
}
func TestPlasticStatusBehind(t *testing.T) {
cases := []struct {
Case string
Expected bool
Status string
Head string
}{
{
Case: "Not behind",
Expected: false,
Status: "STATUS 20 default localhost:8087",
Head: "STATUS cs:20 rep:default repserver:localhost:8087",
},
{
Case: "Behind",
Expected: true,
Status: "STATUS 2 default localhost:8087",
Head: "STATUS cs:20 rep:default repserver:localhost:8087",
},
}
for _, tc := range cases {
p := setupCmStatusEnv(tc.Status, tc.Head)
p.setPlasticStatus()
assert.Equal(t, tc.Expected, p.Behind, tc.Case)
}
}
func TestPlasticStatusChanged(t *testing.T) {
cases := []struct {
Case string
Expected bool
Status string
}{
{
Case: "No changes",
Expected: false,
Status: "STATUS 1 default localhost:8087",
},
{
Case: "Changed file",
Expected: true,
Status: "STATUS 1 default localhost:8087\r\nCH /some.file",
},
{
Case: "Added file",
Expected: true,
Status: "STATUS 1 default localhost:8087\r\nAD /some.file",
},
{
Case: "Added (pivate) file",
Expected: true,
Status: "STATUS 1 default localhost:8087\r\nPR /some.file",
},
{
Case: "Moved file",
Expected: true,
Status: "STATUS 1 default localhost:8087\r\nLM /some.file",
},
{
Case: "Deleted file",
Expected: true,
Status: "STATUS 1 default localhost:8087\r\nLD /some.file",
},
{
Case: "Unmerged file",
Expected: true,
Status: "STATUS 1 default localhost:8087\r\nCO /some.file NO_MERGES",
},
}
for _, tc := range cases {
p := setupCmStatusEnv(tc.Status, "")
p.setPlasticStatus()
assert.Equal(t, tc.Expected, p.Status.Changed(), tc.Case)
}
}
func TestPlasticStatusCounts(t *testing.T) {
status := "STATUS 1 default localhost:8087" +
"\r\nCO /some.file NO_MERGES" +
"\r\nAD /some.file" +
"\r\nCH /some.file\r\nCH /some.file" +
"\r\nLD /some.file\r\nLD /some.file\r\nLD /some.file" +
"\r\nLM /some.file\r\nLM /some.file\r\nLM /some.file\r\nLM /some.file"
p := setupCmStatusEnv(status, "")
p.setPlasticStatus()
s := p.Status
assert.Equal(t, 1, s.Unmerged)
assert.Equal(t, 1, s.Added)
assert.Equal(t, 2, s.Modified)
assert.Equal(t, 3, s.Deleted)
assert.Equal(t, 4, s.Moved)
}
func TestPlasticMergePending(t *testing.T) {
cases := []struct {
Case string
Expected bool
Status string
}{
{
Case: "No pending merge",
Expected: false,
Status: "STATUS 1 default localhost:8087",
},
{
Case: "Pending merge",
Expected: true,
Status: "STATUS 1 default localhost:8087\r\nCH /some.file merge from 8",
},
}
for _, tc := range cases {
p := setupCmStatusEnv(tc.Status, "")
p.setPlasticStatus()
assert.Equal(t, tc.Expected, p.MergePending, tc.Case)
}
}
func TestPlasticParseIntPattern(t *testing.T) {
cases := []struct {
Case string
Expected int
Text string
Pattern string
Name string
Default int
}{
{
Case: "int found",
Expected: 123,
Text: "Some number 123 in text",
Pattern: `\s(?P<x>[0-9]+?)\s`,
Name: "x",
Default: 0,
},
{
Case: "int not found",
Expected: 0,
Text: "No number in text",
Pattern: `\s(?P<x>[0-9]+?)\s`,
Name: "x",
Default: 0,
},
{
Case: "empty text",
Expected: 0,
Text: "",
Pattern: `\s(?P<x>[0-9]+?)\s`,
Name: "x",
Default: 0,
},
}
p := &plastic{}
for _, tc := range cases {
value := p.parseIntPattern(tc.Text, tc.Pattern, tc.Name, tc.Default)
assert.Equal(t, tc.Expected, value, tc.Case)
}
}
func TestPlasticParseStatusChangeset(t *testing.T) {
p := &plastic{}
cs := p.parseStatusChangeset("STATUS 321 default localhost:8087")
assert.Equal(t, 321, cs)
}
func TestPlasticGetHeadChangeset(t *testing.T) {
head := "STATUS cs:321 rep:default repserver:localhost:8087"
p := setupCmStatusEnv("", head)
cs := p.getHeadChangeset()
assert.Equal(t, 321, cs)
}
func TestPlasticParseChangesetSelector(t *testing.T) {
content := "repository \"default\"\r\n path \"/\"\r\n smartbranch \"/main\" changeset \"321\""
p := &plastic{}
selector := p.parseChangesetSelector(content)
assert.Equal(t, "321", selector)
}
func TestPlasticParseLabelSelector(t *testing.T) {
content := "repository \"default\"\r\n path \"/\"\r\n label \"BL003\""
p := &plastic{}
selector := p.parseLabelSelector(content)
assert.Equal(t, "BL003", selector)
}
func TestPlasticParseBranchSelector(t *testing.T) {
content := "repository \"default\"\r\n path \"/\"\r\n branch \"/main/fix-004\""
p := &plastic{}
selector := p.parseBranchSelector(content)
assert.Equal(t, "/main/fix-004", selector)
}
func TestPlasticParseSmartbranchSelector(t *testing.T) {
content := "repository \"default\"\r\n path \"/\"\r\n smartbranch \"/main/fix-002\""
p := &plastic{}
selector := p.parseBranchSelector(content)
assert.Equal(t, "/main/fix-002", selector)
}
func TestPlasticStatus(t *testing.T) {
p := &plastic{
Status: &PlasticStatus{
ScmStatus: ScmStatus{
Added: 1,
Modified: 2,
Deleted: 3,
Moved: 4,
Unmerged: 5,
},
},
}
status := p.Status.String()
expected := "+1 ~2 -3 >4 x5"
assert.Equal(t, expected, status)
}
func TestPlasticTemplateString(t *testing.T) {
cases := []struct {
Case string
Expected string
Template string
Plastic *plastic
}{
{
Case: "Only Selector name",
Expected: "/main",
Template: "{{ .Selector }}",
Plastic: &plastic{
Selector: "/main",
Behind: false,
},
},
{
Case: "Workspace changes",
Expected: "/main \uF044 +2 ~3 -1 >4",
Template: "{{ .Selector }}{{ if .Status.Changed }} \uF044 {{ .Status.String }}{{ end }}",
Plastic: &plastic{
Selector: "/main",
Status: &PlasticStatus{
ScmStatus: ScmStatus{
Added: 2,
Modified: 3,
Deleted: 1,
Moved: 4,
},
},
},
},
{
Case: "No workspace changes",
Expected: "/main",
Template: "{{ .Selector }}{{ if .Status.Changed }} \uF044 {{ .Status.String }}{{ end }}",
Plastic: &plastic{
Selector: "/main",
Status: &PlasticStatus{},
},
},
}
for _, tc := range cases {
var props properties = map[Property]interface{}{
FetchStatus: true,
}
tc.Plastic.props = props
assert.Equal(t, tc.Expected, tc.Plastic.templateString(tc.Template), tc.Case)
}
}

View file

@ -182,7 +182,8 @@
"angular",
"php",
"wifi",
"winreg"
"winreg",
"plastic"
]
},
"style": {
@ -1705,6 +1706,62 @@
}
}
}
},
{
"if": {
"properties": {
"type": { "const": "plastic" }
}
},
"then": {
"title": "Plastic SCM Segment",
"description": "https://ohmyposh.dev/docs/plastic",
"properties": {
"properties": {
"properties": {
"template": {
"$ref": "#/definitions/template"
},
"fetch_status": {
"type": "boolean",
"title": "Display Status",
"description": "Display the local changes or not",
"default": false
},
"branch_icon": {
"type": "string",
"title": "Branch Icon",
"description": "The icon to use in front of the selector branch name",
"default": "\uE0A0 "
},
"commit_icon": {
"type": "string",
"title": "Commit Icon",
"description": "Icon/text to display before the selector changeset",
"default": "\uF417"
},
"tag_icon": {
"type": "string",
"title": "Tag Icon",
"description": "Icon/text to display before the seletor label",
"default": "\uF412"
},
"branch_max_length": {
"type": "integer",
"title": "Branch max length",
"description": "the max length for the displayed branch name where 0 implies full length",
"default": 0
},
"full_branch_path": {
"type": "boolean",
"title": "Full branch path",
"description": "display the full branch path instead of only the branch name",
"default": false
}
}
}
}
}
}
]
}