Repository: JanDeDobbeleer/oh-my-posh
Pull request #1392 (2021-12-08 18:32:40)

feat(plastic): added Plastic SCM segment
### Prerequisites

- [x] I have read and understood the `CONTRIBUTING` guide
- [x] The commit message follows the [conventional commits][cc] guidelines
- [x] Tests for the changes have been added (for bug fixes/features)
- [x] Docs have been added / updated (for bug fixes/features)

### Description

This adds [Plastic SCM](https://www.plasticscm.com/) as a new `plastic` segment. It took inspiration from the `git` segment. 

### Todo

These are points to work on:

- [x] rename `g` to `p`
- [x] remove `StatusColorsEnabled` check
- [x] remove `getCmCommand`
- [x] use `findNamedRegexMatch` more defensively
- [x] refactor `truncateBranch` into new `scm` base type
- [x] refactor `gitStatus` to `scmStatus`
   - [x] switch to new `add` loop logic
- [x] fix `unmerged` counter to reflect files with unhandled merge conflicts
- [x] create new page in `contribute section` with information about setup and manual test of Plastic SCM

[cc]: https://www.conventionalcommits.org/en/v1.0.0/#summary


Khaos66: Hi! Please let me know if I missed anything. I'll be happy to contribute =)
Khaos66: Wow, thank you for the fast review!
I agree with all you points. I see your changes to `git` in #1389 and will apply them to this segment as well.
It may (or may not) take some time, as I'm doing this in my free time. Cheers
Khaos66: Also, is there a place where I could document steps for manual testing? I guess it would be helpfull to document the steps to setup a Plastic SCM server and client with repos and workspaces (as this is not common knowlege).
Maybe in a github issue? 
JanDeDobbeleer: > Also, is there a place where I could document steps for manual testing? I guess it would be helpfull to document the steps to setup a Plastic SCM server and client with repos and workspaces (as this is not common knowlege).
Maybe in a github issue?

@Khaos66 I would create a separate page in the[ contributing section](https://ohmyposh.dev/docs/contributing_started)
Khaos66: I'm trying to get the icon for Plastic SCM added to FontAwesome and ultimately to Nerd-Fonts.
Please help me up-vote the issue
https://github.com/FortAwesome/Font-Awesome/issues/18504

Khaos66: @JanDeDobbeleer I think I'm done. Can you please review again?
JanDeDobbeleer: @Khaos66 only 1 styling comment, amazing PR!
Khaos66: > @Khaos66 only 1 styling comment, amazing PR!

@JanDeDobbeleer Thank you very much! I'm doing what I can =D (first time in `go` though...)

It's done btw ;)

Patch:
diff --git a/docs/docs/contributing-plastic.md b/docs/docs/contributing-plastic.md
new file mode 100644
index 00000000..4d95e4e9
--- /dev/null
+++ b/docs/docs/contributing-plastic.md
@@ -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
diff --git a/docs/docs/segment-plastic.md b/docs/docs/segment-plastic.md
new file mode 100644
index 00000000..331e062c
--- /dev/null
+++ b/docs/docs/segment-plastic.md
@@ -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
diff --git a/docs/sidebars.js b/docs/sidebars.js
index 7f63a016..6cfeed7a 100644
--- a/docs/sidebars.js
+++ b/docs/sidebars.js
@@ -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",
diff --git a/src/scm.go b/src/scm.go
new file mode 100644
index 00000000..6dd34969
--- /dev/null
+++ b/src/scm.go
@@ -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")
+}
diff --git a/src/segment.go b/src/segment.go
index 2e94bf4c..1fa36808 100644
--- a/src/segment.go
+++ b/src/segment.go
@@ -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{},
diff --git a/src/segment_git.go b/src/segment_git.go
index 44b14f37..733f4566 100644
--- a/src/segment_git.go
+++ b/src/segment_git.go
@@ -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
 	}
diff --git a/src/segment_plastic.go b/src/segment_plastic.go
new file mode 100644
index 00000000..8c7a1489
--- /dev/null
+++ b/src/segment_plastic.go
@@ -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
+}
diff --git a/themes/schema.json b/themes/schema.json
index 56bcfa7f..5060123c 100644
--- a/themes/schema.json
+++ b/themes/schema.json
@@ -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
+                  }
+                }
+              }
+            }
+          }
         }
       ]
     }


Test patch:
diff --git a/src/scm_test.go b/src/scm_test.go
new file mode 100644
index 00000000..e0836eee
--- /dev/null
+++ b/src/scm_test.go
@@ -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)
+	}
+}
diff --git a/src/segment_deprecated_test.go b/src/segment_deprecated_test.go
index e7143591..c05632ed 100644
--- a/src/segment_deprecated_test.go
+++ b/src/segment_deprecated_test.go
@@ -13,7 +13,9 @@ import (
 func TestGetStatusDetailStringDefault(t *testing.T) {
 	expected := "icon +1"
 	status := &GitStatus{
-		Added: 1,
+		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{
-		Added: 1,
+		ScmStatus: ScmStatus{
+			Added: 1,
+		},
 	}
 	var props properties = map[Property]interface{}{
 		WorkingColor: "#123456",
 	}
 	g := &git{
-		props: props,
+		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{
-		Added: 1,
+		ScmStatus: ScmStatus{
+			Added: 1,
+		},
 	}
 	var props properties = map[Property]interface{}{
 		WorkingColor:     "#123456",
 		LocalWorkingIcon: "<#789123>work</>",
 	}
 	g := &git{
-		props: props,
+		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{
-		Added: 1,
+		ScmStatus: ScmStatus{
+			Added: 1,
+		},
 	}
 	var props properties = map[Property]interface{}{
 		WorkingColor:     "#123456",
 		LocalWorkingIcon: "work",
 	}
 	g := &git{
-		props: props,
+		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{
-		Added: 1,
+		ScmStatus: ScmStatus{
+			Added: 1,
+		},
 	}
 	var props properties = map[Property]interface{}{
 		DisplayStatusDetail: false,
 	}
 	g := &git{
-		props: props,
+		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{
-		Added: 1,
+		ScmStatus: ScmStatus{
+			Added: 1,
+		},
 	}
 	var props properties = map[Property]interface{}{
 		DisplayStatusDetail: false,
 		WorkingColor:        "#123456",
 	}
 	g := &git{
-		props: props,
+		scm: scm{
+			props: props,
+		},
 	}
 	assert.Equal(t, expected, g.getStatusDetailString(status, WorkingColor, LocalWorkingIcon, "icon"))
 }
@@ -98,9 +120,13 @@ func TestGetStatusColorLocalChangesStaging(t *testing.T) {
 		LocalChangesColor: expected,
 	}
 	g := &git{
-		props: props,
+		scm: scm{
+			props: props,
+		},
 		Staging: &GitStatus{
-			Modified: 1,
+			ScmStatus: ScmStatus{
+				Modified: 1,
+			},
 		},
 		Working: &GitStatus{},
 	}
@@ -113,10 +139,14 @@ func TestGetStatusColorLocalChangesWorking(t *testing.T) {
 		LocalChangesColor: expected,
 	}
 	g := &git{
-		props:   props,
+		scm: scm{
+			props: props,
+		},
 		Staging: &GitStatus{},
 		Working: &GitStatus{
-			Modified: 1,
+			ScmStatus: ScmStatus{
+				Modified: 1,
+			},
 		},
 	}
 	assert.Equal(t, expected, g.getStatusColor("#fg1111"))
@@ -128,7 +158,9 @@ func TestGetStatusColorAheadAndBehind(t *testing.T) {
 		AheadAndBehindColor: expected,
 	}
 	g := &git{
-		props:   props,
+		scm: scm{
+			props: props,
+		},
 		Staging: &GitStatus{},
 		Working: &GitStatus{},
 		Ahead:   1,
@@ -143,7 +175,9 @@ func TestGetStatusColorAhead(t *testing.T) {
 		AheadColor: expected,
 	}
 	g := &git{
-		props:   props,
+		scm: scm{
+			props: props,
+		},
 		Staging: &GitStatus{},
 		Working: &GitStatus{},
 		Ahead:   1,
@@ -158,7 +192,9 @@ func TestGetStatusColorBehind(t *testing.T) {
 		BehindColor: expected,
 	}
 	g := &git{
-		props:   props,
+		scm: scm{
+			props: props,
+		},
 		Staging: &GitStatus{},
 		Working: &GitStatus{},
 		Ahead:   0,
@@ -173,7 +209,9 @@ func TestGetStatusColorDefault(t *testing.T) {
 		BehindColor: changesColor,
 	}
 	g := &git{
-		props:   props,
+		scm: scm{
+			props: props,
+		},
 		Staging: &GitStatus{},
 		Working: &GitStatus{},
 		Ahead:   0,
@@ -189,9 +227,13 @@ func TestSetStatusColorForeground(t *testing.T) {
 		ColorBackground:   false,
 	}
 	g := &git{
-		props: props,
+		scm: scm{
+			props: props,
+		},
 		Staging: &GitStatus{
-			Added: 1,
+			ScmStatus: ScmStatus{
+				Added: 1,
+			},
 		},
 		Working: &GitStatus{},
 	}
@@ -206,10 +248,14 @@ func TestSetStatusColorBackground(t *testing.T) {
 		ColorBackground:   true,
 	}
 	g := &git{
-		props:   props,
+		scm: scm{
+			props: props,
+		},
 		Staging: &GitStatus{},
 		Working: &GitStatus{
-			Modified: 1,
+			ScmStatus: ScmStatus{
+				Modified: 1,
+			},
 		},
 	}
 	g.SetStatusColor()
@@ -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{
-		env:              env,
-		gitWorkingFolder: "",
-		props: map[Property]interface{}{
-			DisplayStatus:       false,
-			StatusColorsEnabled: true,
-			LocalChangesColor:   expected,
+		scm: scm{
+			env: env,
+			props: map[Property]interface{}{
+				DisplayStatus:       false,
+				StatusColorsEnabled: true,
+				LocalChangesColor:   expected,
+			},
 		},
+		gitWorkingFolder: "",
 	}
 	g.Working = &GitStatus{}
 	g.Staging = &GitStatus{}
diff --git a/src/segment_git_test.go b/src/segment_git_test.go
index 501a3cd4..842a281e 100644
--- a/src/segment_git_test.go
+++ b/src/segment_git_test.go
@@ -19,7 +19,9 @@ func TestEnabledGitNotFound(t *testing.T) {
 	env.On("getRuntimeGOOS", nil).Return("")
 	env.On("isWsl", nil).Return(false)
 	g := &git{
-		env: env,
+		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{
-		env: env,
+		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{
-		env: env,
+		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{
-		env: env,
+		scm: scm{
+			env: env,
+		},
 	}
 	got := g.getGitCommandOutput(commandArgs...)
 	assert.Equal(t, want, got)
@@ -217,15 +225,17 @@ func TestSetGitHEADContextClean(t *testing.T) {
 		env.On("getFileContent", "/sequencer/todo").Return(tc.Theirs)
 
 		g := &git{
-			env: env,
-			props: map[Property]interface{}{
-				BranchIcon:     "branch ",
-				CommitIcon:     "commit ",
-				RebaseIcon:     "rebase ",
-				MergeIcon:      "merge ",
-				CherryPickIcon: "pick ",
-				TagIcon:        "tag ",
-				RevertIcon:     "revert ",
+			scm: scm{
+				env: env,
+				props: map[Property]interface{}{
+					BranchIcon:     "branch ",
+					CommitIcon:     "commit ",
+					RebaseIcon:     "rebase ",
+					MergeIcon:      "merge ",
+					CherryPickIcon: "pick ",
+					TagIcon:        "tag ",
+					RevertIcon:     "revert ",
+				},
 			},
 			Hash: "1234567",
 			Ref:  tc.Ref,
@@ -257,11 +267,13 @@ func TestSetPrettyHEADName(t *testing.T) {
 		env.On("isWsl", nil).Return(false)
 		env.mockGitCommand(tc.Tag, "describe", "--tags", "--exact-match")
 		g := &git{
-			env: env,
-			props: map[Property]interface{}{
-				BranchIcon: "branch ",
-				CommitIcon: "commit ",
-				TagIcon:    "tag ",
+			scm: scm{
+				env: env,
+				props: map[Property]interface{}{
+					BranchIcon: "branch ",
+					CommitIcon: "commit ",
+					TagIcon:    "tag ",
+				},
 			},
 			Hash: tc.Hash,
 		}
@@ -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{
-			env: env,
+			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{
-			env:              env,
+			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{
-			env:      env,
-			props:    props,
+			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{
-			props:    props,
+			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{
-			props: props,
-			env:   env,
+			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{
-			env: env,
+			scm: scm{
+				env: env,
+			},
 		}
 		assert.Equal(t, tc.Expected, g.getGitCommand(), tc.Case)
 	}
@@ -628,8 +577,10 @@ func TestGitTemplateString(t *testing.T) {
 			Git: &git{
 				HEAD: branchName,
 				Working: &GitStatus{
-					Added:    2,
-					Modified: 3,
+					ScmStatus: ScmStatus{
+						Added:    2,
+						Modified: 3,
+					},
 				},
 			},
 		},
@@ -649,12 +600,16 @@ func TestGitTemplateString(t *testing.T) {
 			Git: &git{
 				HEAD: branchName,
 				Working: &GitStatus{
-					Added:    2,
-					Modified: 3,
+					ScmStatus: ScmStatus{
+						Added:    2,
+						Modified: 3,
+					},
 				},
 				Staging: &GitStatus{
-					Added:    5,
-					Modified: 1,
+					ScmStatus: ScmStatus{
+						Added:    5,
+						Modified: 1,
+					},
 				},
 			},
 		},
@@ -665,12 +620,16 @@ func TestGitTemplateString(t *testing.T) {
 			Git: &git{
 				HEAD: branchName,
 				Working: &GitStatus{
-					Added:    2,
-					Modified: 3,
+					ScmStatus: ScmStatus{
+						Added:    2,
+						Modified: 3,
+					},
 				},
 				Staging: &GitStatus{
-					Added:    5,
-					Modified: 1,
+					ScmStatus: ScmStatus{
+						Added:    5,
+						Modified: 1,
+					},
 				},
 			},
 		},
@@ -681,12 +640,16 @@ func TestGitTemplateString(t *testing.T) {
 			Git: &git{
 				HEAD: branchName,
 				Working: &GitStatus{
-					Added:    2,
-					Modified: 3,
+					ScmStatus: ScmStatus{
+						Added:    2,
+						Modified: 3,
+					},
 				},
 				Staging: &GitStatus{
-					Added:    5,
-					Modified: 1,
+					ScmStatus: ScmStatus{
+						Added:    5,
+						Modified: 1,
+					},
 				},
 				StashCount: 3,
 			},
diff --git a/src/segment_plastic_test.go b/src/segment_plastic_test.go
new file mode 100644
index 00000000..54fb670c
--- /dev/null
+++ b/src/segment_plastic_test.go
@@ -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)
+	}
+}