From 457f439a9ffa1af1ddc72fa07ac3e81ce17b3ced Mon Sep 17 00:00:00 2001 From: Khaos Date: Sat, 11 Dec 2021 22:08:47 +0100 Subject: [PATCH] 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 Co-authored-by: Jan De Dobbeleer <2492783+JanDeDobbeleer@users.noreply.github.com> --- docs/docs/contributing-plastic.md | 352 ++++++++++++++++++++++++++++++ docs/docs/segment-plastic.md | 97 ++++++++ docs/sidebars.js | 2 + src/scm.go | 81 +++++++ src/scm_test.go | 186 ++++++++++++++++ src/segment.go | 3 + src/segment_deprecated_test.go | 106 ++++++--- src/segment_git.go | 73 ++----- src/segment_git_test.go | 201 +++++++---------- src/segment_plastic.go | 192 ++++++++++++++++ src/segment_plastic_test.go | 330 ++++++++++++++++++++++++++++ themes/schema.json | 59 ++++- 12 files changed, 1474 insertions(+), 208 deletions(-) create mode 100644 docs/docs/contributing-plastic.md create mode 100644 docs/docs/segment-plastic.md create mode 100644 src/scm.go create mode 100644 src/scm_test.go create mode 100644 src/segment_plastic.go create mode 100644 src/segment_plastic_test.go 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/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.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_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.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 )?(?Pbranch|commit|tag) '(?P.*)'`, 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(`^(?Pp|pick|revert)\s+(?P\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_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.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[0-9]+?)\s`, "cs", 0) +} + +func (p *plastic) getHeadChangeset() int { + output := p.getCmCommandOutput("status", "--head", "--machinereadable") + return p.parseIntPattern(output, `\bcs:(?P[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[0-9]+?)"`, "cs") +} + +func (p *plastic) parseLabelSelector(selector string) string { + return p.parseStringPattern(selector, `label "(?P