Repository: JanDeDobbeleer/oh-my-posh
Pull request #2008 (2022-03-30 22:43:17)

feat: added svn 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

Added a Subversion segment which shows current branch, base revision number the different file change category counts. Flags are also included to indicate changes are present and also if merge conflicts are detected.

I think I have completed everything correctly, this is my first attempt at using Go. Please let me know if I missed anything or did something sub-optimally and I will try to correct it.

[cc]: https://www.conventionalcommits.org/en/v1.0.0/#summary



Patch:
diff --git a/src/engine/segment.go b/src/engine/segment.go
index 40066a37..7dd0f9d4 100644
--- a/src/engine/segment.go
+++ b/src/engine/segment.go
@@ -163,6 +163,8 @@ const (
 	SPOTIFY SegmentType = "spotify"
 	// STRAVA is a sports activity tracker
 	STRAVA SegmentType = "strava"
+	// Subversion segment
+	SVN SegmentType = "svn"
 	// SWIFT writes the active swift version
 	SWIFT SegmentType = "swift"
 	// SYSTEMINFO writes system information (memory, cpu, load)
@@ -296,6 +298,7 @@ func (segment *Segment) mapSegmentWithWriter(env environment.Environment) error
 		SHELL:         &segments.Shell{},
 		SPOTIFY:       &segments.Spotify{},
 		STRAVA:        &segments.Strava{},
+		SVN:           &segments.Svn{},
 		SWIFT:         &segments.Swift{},
 		SYSTEMINFO:    &segments.SystemInfo{},
 		TERRAFORM:     &segments.Terraform{},
diff --git a/src/mock/environment.go b/src/mock/environment.go
index 51b014a4..36b1f35c 100644
--- a/src/mock/environment.go
+++ b/src/mock/environment.go
@@ -221,6 +221,11 @@ func (env *MockedEnvironment) MockGitCommand(dir, returnValue string, args ...st
 	env.On("RunCommand", "git", args).Return(returnValue, nil)
 }
 
+func (env *MockedEnvironment) MockSvnCommand(dir, returnValue string, args ...string) {
+	args = append([]string{"-C", dir, "--no-optional-locks", "-c", "core.quotepath=false", "-c", "color.status=false"}, args...)
+	env.On("RunCommand", "svn", args).Return(returnValue, nil)
+}
+
 func (env *MockedEnvironment) HasFileInParentDirs(pattern string, depth uint) bool {
 	args := env.Called(pattern, depth)
 	return args.Bool(0)
diff --git a/src/segments/git.go b/src/segments/git.go
index d4582c7c..5d1b4e9d 100644
--- a/src/segments/git.go
+++ b/src/segments/git.go
@@ -2,7 +2,6 @@ package segments
 
 import (
 	"fmt"
-	"oh-my-posh/environment"
 	"oh-my-posh/properties"
 	"oh-my-posh/regex"
 	"strconv"
@@ -22,8 +21,10 @@ func (s *GitStatus) add(code string) {
 		return
 	case "D":
 		s.Deleted++
-	case "A", "?":
+	case "A":
 		s.Added++
+	case "?":
+		s.Untracked++
 	case "U":
 		s.Unmerged++
 	case "M", "R", "C", "m":
@@ -49,14 +50,6 @@ type Git struct {
 	StashCount    int
 	WorktreeCount int
 	IsWorkTree    bool
-
-	gitWorkingFolder string // .git working folder
-	gitRootFolder    string // .git root folder
-	gitRealFolder    string // .git real folder(can be different from current path when in worktrees)
-
-	gitCommand string
-
-	IsWslSharedPath bool
 }
 
 const (
@@ -108,6 +101,7 @@ const (
 
 	DETACHED     = "(detached)"
 	BRANCHPREFIX = "ref: refs/heads/"
+	GITCOMMAND   = "git"
 )
 
 func (g *Git) Template() string {
@@ -145,7 +139,7 @@ func (g *Git) shouldDisplay() bool {
 	// we must use git.exe and convert paths accordingly
 	// for worktrees, stashes, and path to work
 	g.IsWslSharedPath = g.env.InWSLSharedDrive()
-	if !g.env.HasCommand(g.getGitCommand()) {
+	if !g.env.HasCommand(g.getCommand(GITCOMMAND)) {
 		return false
 	}
 	gitdir, err := g.env.HasParentFilePath(".git")
@@ -157,48 +151,48 @@ func (g *Git) shouldDisplay() bool {
 	}
 
 	if gitdir.IsDir {
-		g.gitWorkingFolder = gitdir.Path
-		g.gitRootFolder = gitdir.Path
+		g.workingFolder = gitdir.Path
+		g.rootFolder = gitdir.Path
 		// convert the worktree file path to a windows one when in wsl 2 shared folder
-		g.gitRealFolder = strings.TrimSuffix(g.convertToWindowsPath(gitdir.Path), ".git")
+		g.realFolder = strings.TrimSuffix(g.convertToWindowsPath(gitdir.Path), ".git")
 		return true
 	}
 	// handle worktree
-	g.gitRootFolder = gitdir.Path
+	g.rootFolder = gitdir.Path
 	dirPointer := strings.Trim(g.env.FileContent(gitdir.Path), " \r\n")
 	matches := regex.FindNamedRegexMatch(`^gitdir: (?P<dir>.*)$`, dirPointer)
 	if matches != nil && matches["dir"] != "" {
 		// if we open a worktree file in a shared wsl2 folder, we have to convert it back
 		// to the mounted path
-		g.gitWorkingFolder = g.convertToLinuxPath(matches["dir"])
+		g.workingFolder = g.convertToLinuxPath(matches["dir"])
 
 		// in worktrees, the path looks like this: gitdir: path/.git/worktrees/branch
 		// strips the last .git/worktrees part
 		// :ind+5 = index + /.git
-		ind := strings.LastIndex(g.gitWorkingFolder, "/.git/worktrees")
+		ind := strings.LastIndex(g.workingFolder, "/.git/worktrees")
 		if ind > -1 {
-			g.gitRootFolder = g.gitWorkingFolder[:ind+5]
-			g.gitRealFolder = strings.TrimSuffix(g.env.FileContent(g.gitWorkingFolder+"/gitdir"), ".git\n")
+			g.rootFolder = g.workingFolder[:ind+5]
+			g.realFolder = strings.TrimSuffix(g.env.FileContent(g.workingFolder+"/gitdir"), ".git\n")
 			g.IsWorkTree = true
 			return true
 		}
 		// in submodules, the path looks like this: gitdir: ../.git/modules/test-submodule
 		// we need the parent folder to detect where the real .git folder is
-		ind = strings.LastIndex(g.gitWorkingFolder, "/.git/modules")
+		ind = strings.LastIndex(g.workingFolder, "/.git/modules")
 		if ind > -1 {
-			g.gitRootFolder = gitdir.ParentFolder + "/" + g.gitWorkingFolder
-			g.gitRealFolder = g.gitRootFolder
-			g.gitWorkingFolder = g.gitRootFolder
+			g.rootFolder = gitdir.ParentFolder + "/" + g.workingFolder
+			g.realFolder = g.rootFolder
+			g.workingFolder = g.rootFolder
 			return true
 		}
 
 		// check for separate git folder(--separate-git-dir)
 		// check if the folder contains a HEAD file
-		if g.env.HasFilesInDir(g.gitWorkingFolder, "HEAD") {
-			gitFolder := strings.TrimSuffix(g.gitRootFolder, ".git")
-			g.gitRootFolder = g.gitWorkingFolder
-			g.gitWorkingFolder = gitFolder
-			g.gitRealFolder = gitFolder
+		if g.env.HasFilesInDir(g.workingFolder, "HEAD") {
+			gitFolder := strings.TrimSuffix(g.rootFolder, ".git")
+			g.rootFolder = g.workingFolder
+			g.workingFolder = gitFolder
+			g.realFolder = gitFolder
 			return true
 		}
 		return false
@@ -304,20 +298,9 @@ func (g *Git) setGitStatus() {
 	}
 }
 
-func (g *Git) getGitCommand() string {
-	if len(g.gitCommand) > 0 {
-		return g.gitCommand
-	}
-	g.gitCommand = "git"
-	if g.env.GOOS() == environment.WindowsPlatform || g.IsWslSharedPath {
-		g.gitCommand = "git.exe"
-	}
-	return g.gitCommand
-}
-
 func (g *Git) getGitCommandOutput(args ...string) string {
-	args = append([]string{"-C", g.gitRealFolder, "--no-optional-locks", "-c", "core.quotepath=false", "-c", "color.status=false"}, args...)
-	val, err := g.env.RunCommand(g.getGitCommand(), args...)
+	args = append([]string{"-C", g.realFolder, "--no-optional-locks", "-c", "core.quotepath=false", "-c", "color.status=false"}, args...)
+	val, err := g.env.RunCommand(g.getCommand(GITCOMMAND), args...)
 	if err != nil {
 		return ""
 	}
@@ -342,7 +325,7 @@ func (g *Git) setGitHEADContext() {
 
 	getPrettyNameOrigin := func(file string) string {
 		var origin string
-		head := g.FileContents(g.gitWorkingFolder, file)
+		head := g.FileContents(g.workingFolder, file)
 		if head == "detached HEAD" {
 			origin = formatDetached()
 		} else {
@@ -352,20 +335,20 @@ func (g *Git) setGitHEADContext() {
 		return origin
 	}
 
-	if g.env.HasFolder(g.gitWorkingFolder + "/rebase-merge") {
+	if g.env.HasFolder(g.workingFolder + "/rebase-merge") {
 		origin := getPrettyNameOrigin("rebase-merge/head-name")
 		onto := g.getGitRefFileSymbolicName("rebase-merge/onto")
 		onto = g.formatHEAD(onto)
-		step := g.FileContents(g.gitWorkingFolder, "rebase-merge/msgnum")
-		total := g.FileContents(g.gitWorkingFolder, "rebase-merge/end")
+		step := g.FileContents(g.workingFolder, "rebase-merge/msgnum")
+		total := g.FileContents(g.workingFolder, "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") {
+	if g.env.HasFolder(g.workingFolder + "/rebase-apply") {
 		origin := getPrettyNameOrigin("rebase-apply/head-name")
-		step := g.FileContents(g.gitWorkingFolder, "rebase-apply/next")
-		total := g.FileContents(g.gitWorkingFolder, "rebase-apply/last")
+		step := g.FileContents(g.workingFolder, "rebase-apply/next")
+		total := g.FileContents(g.workingFolder, "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
@@ -374,7 +357,7 @@ func (g *Git) setGitHEADContext() {
 	commitIcon := g.props.GetString(CommitIcon, "\uF417")
 	if g.hasGitFile("MERGE_MSG") {
 		icon := g.props.GetString(MergeIcon, "\uE727 ")
-		mergeContext := g.FileContents(g.gitWorkingFolder, "MERGE_MSG")
+		mergeContext := g.FileContents(g.workingFolder, "MERGE_MSG")
 		matches := regex.FindNamedRegexMatch(`Merge (remote-tracking )?(?P<type>branch|commit|tag) '(?P<theirs>.*)'`, mergeContext)
 		// head := g.getGitRefFileSymbolicName("ORIG_HEAD")
 		if matches != nil && matches["theirs"] != "" {
@@ -400,19 +383,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.FileContents(g.gitWorkingFolder, "CHERRY_PICK_HEAD")
+		sha := g.FileContents(g.workingFolder, "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.FileContents(g.gitWorkingFolder, "REVERT_HEAD")
+		sha := g.FileContents(g.workingFolder, "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.FileContents(g.gitWorkingFolder, "sequencer/todo")
+		todo := g.FileContents(g.workingFolder, "sequencer/todo")
 		matches := regex.FindNamedRegexMatch(`^(?P<action>p|pick|revert)\s+(?P<sha>\S+)`, todo)
 		if matches != nil && matches["sha"] != "" {
 			action := matches["action"]
@@ -449,18 +432,18 @@ func (g *Git) formatSHA(sha string) string {
 }
 
 func (g *Git) hasGitFile(file string) bool {
-	return g.env.HasFilesInDir(g.gitWorkingFolder, file)
+	return g.env.HasFilesInDir(g.workingFolder, file)
 }
 
 func (g *Git) getGitRefFileSymbolicName(refFile string) string {
-	ref := g.FileContents(g.gitWorkingFolder, refFile)
+	ref := g.FileContents(g.workingFolder, 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.FileContents(g.gitWorkingFolder, "HEAD")
+		HEADRef := g.FileContents(g.workingFolder, "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))
@@ -486,7 +469,7 @@ func (g *Git) setPrettyHEADName() {
 }
 
 func (g *Git) getStashContext() int {
-	stashContent := g.FileContents(g.gitRootFolder, "logs/refs/stash")
+	stashContent := g.FileContents(g.rootFolder, "logs/refs/stash")
 	if stashContent == "" {
 		return 0
 	}
@@ -495,10 +478,10 @@ func (g *Git) getStashContext() int {
 }
 
 func (g *Git) getWorktreeContext() int {
-	if !g.env.HasFolder(g.gitRootFolder + "/worktrees") {
+	if !g.env.HasFolder(g.rootFolder + "/worktrees") {
 		return 0
 	}
-	worktreeFolders := g.env.LsDir(g.gitRootFolder + "/worktrees")
+	worktreeFolders := g.env.LsDir(g.rootFolder + "/worktrees")
 	var count int
 	for _, folder := range worktreeFolders {
 		if folder.IsDir() {
@@ -520,7 +503,7 @@ func (g *Git) getOriginURL(upstream string) string {
 		return fmt.Sprintf("https://%s", url)
 	}
 	var url string
-	cfg, err := ini.Load(g.gitRootFolder + "/config")
+	cfg, err := ini.Load(g.rootFolder + "/config")
 	if err != nil {
 		url = g.getGitCommandOutput("remote", "get-url", upstream)
 		return cleanSSHURL(url)
@@ -532,24 +515,10 @@ func (g *Git) getOriginURL(upstream string) string {
 	return cleanSSHURL(url)
 }
 
-func (g *Git) convertToWindowsPath(path string) string {
-	if !g.IsWslSharedPath {
-		return path
-	}
-	return g.env.ConvertToWindowsPath(path)
-}
-
-func (g *Git) convertToLinuxPath(path string) string {
-	if !g.IsWslSharedPath {
-		return path
-	}
-	return g.env.ConvertToLinuxPath(path)
-}
-
 func (g *Git) getUntrackedFilesMode() string {
 	mode := "normal"
 	repoModes := g.props.GetKeyValueMap(UntrackedModes, map[string]string{})
-	if val := repoModes[g.gitRealFolder]; len(val) != 0 {
+	if val := repoModes[g.realFolder]; len(val) != 0 {
 		mode = val
 	}
 	return fmt.Sprintf("-u%s", mode)
diff --git a/src/segments/scm.go b/src/segments/scm.go
index 13c8debd..1dda627c 100644
--- a/src/segments/scm.go
+++ b/src/segments/scm.go
@@ -9,15 +9,17 @@ import (
 
 // ScmStatus represents part of the status of a repository
 type ScmStatus struct {
-	Unmerged int
-	Deleted  int
-	Added    int
-	Modified int
-	Moved    int
+	Unmerged   int
+	Deleted    int
+	Added      int
+	Modified   int
+	Moved      int
+	Conflicted int
+	Untracked  int
 }
 
 func (s *ScmStatus) Changed() bool {
-	return s.Added > 0 || s.Deleted > 0 || s.Modified > 0 || s.Unmerged > 0 || s.Moved > 0
+	return s.Added > 0 || s.Deleted > 0 || s.Modified > 0 || s.Unmerged > 0 || s.Moved > 0 || s.Conflicted > 0
 }
 
 func (s *ScmStatus) String() string {
@@ -28,17 +30,25 @@ func (s *ScmStatus) String() string {
 		}
 		return ""
 	}
+	status += stringIfValue(s.Untracked, "?")
 	status += stringIfValue(s.Added, "+")
 	status += stringIfValue(s.Modified, "~")
 	status += stringIfValue(s.Deleted, "-")
 	status += stringIfValue(s.Moved, ">")
 	status += stringIfValue(s.Unmerged, "x")
+	status += stringIfValue(s.Conflicted, "!")
 	return strings.TrimSpace(status)
 }
 
 type scm struct {
 	props properties.Properties
 	env   environment.Environment
+
+	IsWslSharedPath bool
+	workingFolder   string
+	rootFolder      string
+	realFolder      string // real folder (can be different from current path when in worktrees)
+	command         string
 }
 
 const (
@@ -80,3 +90,28 @@ func (s *scm) shouldIgnoreRootRepository(rootDir string) bool {
 func (s *scm) FileContents(folder, file string) string {
 	return strings.Trim(s.env.FileContent(folder+"/"+file), " \r\n")
 }
+
+func (s *scm) convertToWindowsPath(path string) string {
+	if !s.IsWslSharedPath {
+		return path
+	}
+	return s.env.ConvertToWindowsPath(path)
+}
+
+func (s *scm) convertToLinuxPath(path string) string {
+	if !s.IsWslSharedPath {
+		return path
+	}
+	return s.env.ConvertToLinuxPath(path)
+}
+
+func (s *scm) getCommand(command string) string {
+	if len(s.command) > 0 {
+		return s.command
+	}
+	s.command = command
+	if s.env.GOOS() == environment.WindowsPlatform || s.IsWslSharedPath {
+		s.command += ".exe"
+	}
+	return s.command
+}
diff --git a/src/segments/svn.go b/src/segments/svn.go
new file mode 100644
index 00000000..2ac8fc84
--- /dev/null
+++ b/src/segments/svn.go
@@ -0,0 +1,130 @@
+package segments
+
+import (
+	"oh-my-posh/regex"
+	"strconv"
+	"strings"
+)
+
+// SvnStatus represents part of the status of a Svn repository
+type SvnStatus struct {
+	ScmStatus
+}
+
+func (s *SvnStatus) add(code string) {
+	switch code {
+	case "C":
+		s.Conflicted++
+	case "D":
+		s.Deleted++
+	case "A":
+		s.Added++
+	case "M":
+		s.Modified++
+	case "R":
+		s.Moved++
+	default:
+		s.Unmerged++
+	}
+}
+
+func (s *SvnStatus) HasConflicts() bool {
+	return s.Conflicted > 0
+}
+
+const (
+	SVNCOMMAND = "svn"
+)
+
+type Svn struct {
+	scm
+
+	Working *SvnStatus
+	BaseRev int
+	Branch  string
+}
+
+func (s *Svn) Template() string {
+	return " \ue0a0{{.Branch}} r{{.BaseRev}} {{.Working.String}} "
+}
+
+func (s *Svn) Enabled() bool {
+	if !s.shouldDisplay() {
+		return false
+	}
+	displayStatus := s.props.GetBool(FetchStatus, false)
+	if displayStatus {
+		s.setSvnStatus()
+	} else {
+		s.Working = &SvnStatus{}
+	}
+	return true
+}
+
+func (s *Svn) shouldDisplay() bool {
+	// when in wsl/wsl2 and in a windows shared folder
+	// we must use Svn.exe and convert paths accordingly
+	// for worktrees, stashes, and path to work
+	s.IsWslSharedPath = s.env.InWSLSharedDrive()
+	if !s.env.HasCommand(s.getCommand(SVNCOMMAND)) {
+		return false
+	}
+	Svndir, err := s.env.HasParentFilePath(".svn")
+	if err != nil {
+		return false
+	}
+	if s.shouldIgnoreRootRepository(Svndir.ParentFolder) {
+		return false
+	}
+
+	if Svndir.IsDir {
+		s.workingFolder = Svndir.Path
+		s.rootFolder = Svndir.Path
+		// convert the worktree file path to a windows one when in wsl 2 shared folder
+		s.realFolder = strings.TrimSuffix(s.convertToWindowsPath(Svndir.Path), ".svn")
+		return true
+	}
+	// handle worktree
+	s.rootFolder = Svndir.Path
+	dirPointer := strings.Trim(s.env.FileContent(Svndir.Path), " \r\n")
+	matches := regex.FindNamedRegexMatch(`^Svndir: (?P<dir>.*)$`, dirPointer)
+	if matches != nil && matches["dir"] != "" {
+		// if we open a worktree file in a shared wsl2 folder, we have to convert it back
+		// to the mounted path
+		s.workingFolder = s.convertToLinuxPath(matches["dir"])
+		return false
+	}
+	return false
+}
+
+func (s *Svn) setSvnStatus() {
+	s.BaseRev, _ = strconv.Atoi(s.getSvnCommandOutput("info", "--show-item", "revision"))
+
+	branch := s.getSvnCommandOutput("info", "--show-item", "relative-url")
+	if len(branch) > 2 {
+		s.Branch = branch[2:]
+	}
+
+	s.Working = &SvnStatus{}
+	changes := s.getSvnCommandOutput("status")
+	if len(changes) == 0 {
+		return
+	}
+	lines := strings.Split(changes, "\n")
+	for _, line := range lines {
+		if len(line) == 0 {
+			continue
+		}
+		// element is the element from someSlice for where we are
+		s.Working.add(line[0:1])
+	}
+}
+
+func (s *Svn) getSvnCommandOutput(command string, args ...string) string {
+	args = append([]string{command, s.realFolder}, args...)
+	val, err := s.env.RunCommand(s.getCommand(SVNCOMMAND), args...)
+	if err != nil {
+		return ""
+	}
+	return strings.TrimSpace(val)
+}
diff --git a/website/docs/segments/git.mdx b/website/docs/segments/git.mdx
index f06d54cd..6c17849b 100644
--- a/website/docs/segments/git.mdx
+++ b/website/docs/segments/git.mdx
@@ -110,7 +110,7 @@ You can set the following properties to `true` to enable fetching additional inf
 
 ### Properties
 
-- `.Working`: `GitStatus` - changes in the working tree (see below)
+- `.Working`: `GitStatus` - changes in the worktree (see below)
 - `.Staging`: `GitStatus` - staged changes in the work tree (see below)
 - `.HEAD`: `string` - the current HEAD context (branch/rebase/merge/...)
 - `.Behind`: `int` - commits behind of upstream
diff --git a/website/docs/segments/svn.md b/website/docs/segments/svn.md
new file mode 100644
index 00000000..6fbd4a41
--- /dev/null
+++ b/website/docs/segments/svn.md
@@ -0,0 +1,76 @@
+---
+id: svn
+title: Svn
+sidebar_label: Svn
+---
+
+## What
+
+Display svn information when in a svn repository. Also works for subfolders. For maximum compatibility,
+make sure your `svn` 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:
+
+- `+` added
+- `!` conflicted
+- `-` deleted
+- `~` modified
+- `>` moved
+- `?` untracked
+
+## Sample Configuration
+
+```json
+{
+  "type": "svn",
+  "style": "powerline",
+  "powerline_symbol": "\uE0B0",
+  "foreground": "#193549",
+  "background": "#ffeb3b",
+  "properties": {
+    "fetch_status": true,
+    "fetch_stash_count": true,
+    "fetch_upstream_icon": true,
+    "template": " \ue0a0{{ .Branch }} r{{ .BaseRev }} {{ .Working.String }} "
+  }
+}
+```
+
+## Properties
+
+### Fetching information
+
+As doing multiple svn calls can slow down the prompt experience, we do not fetch information by default.
+You can set the following properties to `true` to enable fetching additional information (and populate the template).
+
+- fetch_status: `boolean` - fetch the local changes - defaults to `false`
+
+## Template ([info][templates])
+
+:::note default template
+
+``` template
+ \ue0a0{{.Branch}} r{{.BaseRev}} {{.Working.String}}
+```
+
+:::
+
+### Properties
+
+- `.Working`: `SvnStatus` - changes in the worktree (see below)
+- `.Branch`: `string` - current branch (releative URL reported by `svn info`)
+- `.BaseRev`: `int` - the currently checked out revision number
+
+### SvnStatus
+
+- `.Untracked`: `int` - number of untracked files
+- `.Deleted`: `int` - number of deleted files
+- `.Added`: `int` - number of added files
+- `.Tracked`: `int` - number of changed tracked files
+- `.Conflicted`: `int` - number of changed tracked files with conflicts
+- `.Changed`: `boolean` - if the status contains changes or not
+- `.HasConflicts`: `boolean` - if the status contains conflicts or not
+- `.String`: `string` - a string representation of the changes above
+
+[templates]: /docs/config-templates
+[hyperlinks]: /docs/config-templates#helper-functions


Test patch:
diff --git a/src/segments/git_test.go b/src/segments/git_test.go
index 254430c1..521b89df 100644
--- a/src/segments/git_test.go
+++ b/src/segments/git_test.go
@@ -51,7 +51,7 @@ func TestEnabledInWorkingDirectory(t *testing.T) {
 		},
 	}
 	assert.True(t, g.Enabled())
-	assert.Equal(t, fileInfo.Path, g.gitWorkingFolder)
+	assert.Equal(t, fileInfo.Path, g.workingFolder)
 }
 
 func TestEnabledInWorkingTree(t *testing.T) {
@@ -77,8 +77,8 @@ func TestEnabledInWorkingTree(t *testing.T) {
 		},
 	}
 	assert.True(t, g.Enabled())
-	assert.Equal(t, "/dev/real_folder/.git/worktrees/folder_worktree", g.gitWorkingFolder)
-	assert.Equal(t, "/dev/folder_worktree", g.gitRealFolder)
+	assert.Equal(t, "/dev/real_folder/.git/worktrees/folder_worktree", g.workingFolder)
+	assert.Equal(t, "/dev/folder_worktree", g.realFolder)
 }
 
 func TestEnabledInSubmodule(t *testing.T) {
@@ -104,9 +104,9 @@ func TestEnabledInSubmodule(t *testing.T) {
 		},
 	}
 	assert.True(t, g.Enabled())
-	assert.Equal(t, "/dev/parent/test-submodule/../.git/modules/test-submodule", g.gitWorkingFolder)
-	assert.Equal(t, "/dev/parent/test-submodule/../.git/modules/test-submodule", g.gitRealFolder)
-	assert.Equal(t, "/dev/parent/test-submodule/../.git/modules/test-submodule", g.gitRootFolder)
+	assert.Equal(t, "/dev/parent/test-submodule/../.git/modules/test-submodule", g.workingFolder)
+	assert.Equal(t, "/dev/parent/test-submodule/../.git/modules/test-submodule", g.realFolder)
+	assert.Equal(t, "/dev/parent/test-submodule/../.git/modules/test-submodule", g.rootFolder)
 }
 
 func TestEnabledInSeparateGitDir(t *testing.T) {
@@ -132,9 +132,9 @@ func TestEnabledInSeparateGitDir(t *testing.T) {
 		},
 	}
 	assert.True(t, g.Enabled())
-	assert.Equal(t, "/dev/parent/test-separate-git-dir/", g.gitWorkingFolder)
-	assert.Equal(t, "/dev/parent/test-separate-git-dir/", g.gitRealFolder)
-	assert.Equal(t, "/dev/separate-git-dir", g.gitRootFolder)
+	assert.Equal(t, "/dev/parent/test-separate-git-dir/", g.workingFolder)
+	assert.Equal(t, "/dev/parent/test-separate-git-dir/", g.realFolder)
+	assert.Equal(t, "/dev/separate-git-dir", g.rootFolder)
 }
 func TestGetGitOutputForCommand(t *testing.T) {
 	args := []string{"-C", "", "--no-optional-locks", "-c", "core.quotepath=false", "-c", "color.status=false"}
@@ -368,7 +368,7 @@ func TestSetGitStatus(t *testing.T) {
 			1 .C N...
 			1 .M N...
 			1 .m N...
-			1 .? N...
+			1 .A N...
 			1 .D N...
 			1 .A N...
 			1 .U N...
@@ -391,7 +391,7 @@ func TestSetGitStatus(t *testing.T) {
 			1 .C N...
 			1 .M N...
 			1 .m N...
-			1 .? N...
+			1 .A N...
 			1 .D N...
 			1 .A N...
 			1 .U N...
@@ -443,7 +443,7 @@ func TestSetGitStatus(t *testing.T) {
 			ExpectedUpstream: "origin/main",
 			ExpectedHash:     "1234567",
 			ExpectedRef:      "main",
-			ExpectedWorking:  &GitStatus{ScmStatus: ScmStatus{Added: 3}},
+			ExpectedWorking:  &GitStatus{ScmStatus: ScmStatus{Untracked: 3}},
 		},
 		{
 			Case: "remote branch was deleted",
@@ -501,9 +501,9 @@ func TestGetStashContextZeroEntries(t *testing.T) {
 		env.On("FileContent", "/logs/refs/stash").Return(tc.StashContent)
 		g := &Git{
 			scm: scm{
-				env: env,
+				env:           env,
+				workingFolder: "",
 			},
-			gitWorkingFolder: "",
 		}
 		got := g.getStashContext()
 		assert.Equal(t, tc.Expected, got)
@@ -625,7 +625,7 @@ func TestGetGitCommand(t *testing.T) {
 		} else {
 			env.On("InWSLSharedDrive").Return(false)
 		}
-		assert.Equal(t, tc.Expected, g.getGitCommand(), tc.Case)
+		assert.Equal(t, tc.Expected, g.getCommand(GITCOMMAND), tc.Case)
 	}
 }
 
@@ -796,8 +796,8 @@ func TestGitUntrackedMode(t *testing.T) {
 				props: properties.Map{
 					UntrackedModes: tc.UntrackedModes,
 				},
+				realFolder: "foo",
 			},
-			gitRealFolder: "foo",
 		}
 		got := g.getUntrackedFilesMode()
 		assert.Equal(t, tc.Expected, got, tc.Case)
diff --git a/src/segments/svn_test.go b/src/segments/svn_test.go
new file mode 100644
index 00000000..af47a2d5
--- /dev/null
+++ b/src/segments/svn_test.go
@@ -0,0 +1,251 @@
+package segments
+
+import (
+	"oh-my-posh/environment"
+	"oh-my-posh/mock"
+	"oh-my-posh/properties"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSvnEnabledToolNotFound(t *testing.T) {
+	env := new(mock.MockedEnvironment)
+	env.On("InWSLSharedDrive").Return(false)
+	env.On("HasCommand", "svn").Return(false)
+	env.On("GOOS").Return("")
+	env.On("IsWsl").Return(false)
+	s := &Svn{
+		scm: scm{
+			env:   env,
+			props: properties.Map{},
+		},
+	}
+	assert.False(t, s.Enabled())
+}
+
+func TestSvnEnabledInWorkingDirectory(t *testing.T) {
+	fileInfo := &environment.FileInfo{
+		Path:         "/dir/hello",
+		ParentFolder: "/dir",
+		IsDir:        true,
+	}
+	env := new(mock.MockedEnvironment)
+	env.On("InWSLSharedDrive").Return(false)
+	env.On("HasCommand", "svn").Return(true)
+	env.On("GOOS").Return("")
+	env.On("FileContent", "/dir/hello/trunk").Return("")
+	env.MockSvnCommand(fileInfo.Path, "", "info", "--tags", "--exact-match")
+	env.On("IsWsl").Return(false)
+	env.On("HasParentFilePath", ".svn").Return(fileInfo, nil)
+	s := &Svn{
+		scm: scm{
+			env:   env,
+			props: properties.Map{},
+		},
+	}
+	assert.True(t, s.Enabled())
+	assert.Equal(t, fileInfo.Path, s.workingFolder)
+	assert.Equal(t, fileInfo.Path, s.realFolder)
+}
+
+func TestSvnTemplateString(t *testing.T) {
+	cases := []struct {
+		Case     string
+		Expected string
+		Template string
+		Svn      *Svn
+	}{
+		{
+			Case:     "Default template",
+			Expected: "\ue0a0trunk r2 +2 ~3 -7 >13 x5 !1",
+			Template: " \ue0a0{{.Branch}} r{{.BaseRev}} {{.Working.String}} ",
+			Svn: &Svn{
+				Branch:  "trunk",
+				BaseRev: 2,
+				Working: &SvnStatus{
+					ScmStatus: ScmStatus{
+						Added:      2,
+						Conflicted: 1,
+						Deleted:    7,
+						Modified:   3,
+						Moved:      13,
+						Unmerged:   5,
+					},
+				},
+			},
+		},
+		{
+			Case:     "Only Branch name",
+			Expected: "trunk",
+			Template: "{{ .Branch }}",
+			Svn: &Svn{
+				Branch:  "trunk",
+				BaseRev: 2,
+			},
+		},
+		{
+			Case:     "Working area changes",
+			Expected: "trunk \uF044 +2 ~3",
+			Template: "{{ .Branch }}{{ if .Working.Changed }} \uF044 {{ .Working.String }}{{ end }}",
+			Svn: &Svn{
+				Branch: "trunk",
+				Working: &SvnStatus{
+					ScmStatus: ScmStatus{
+						Added:    2,
+						Modified: 3,
+					},
+				},
+			},
+		},
+		{
+			Case:     "No working area changes (using changed flag)",
+			Expected: "trunk",
+			Template: "{{ .Branch }}{{ if .Working.Changed }} \uF044 {{ .Working.String }}{{ end }}",
+			Svn: &Svn{
+				Branch:  "trunk",
+				Working: &SvnStatus{},
+			},
+		},
+		{
+			Case:     "No working area changes",
+			Expected: "trunk",
+			Template: "{{ .Branch }}{{ .Working.String }}",
+			Svn: &Svn{
+				Branch:  "trunk",
+				Working: &SvnStatus{},
+			},
+		},
+		{
+			Case:     "Base revision with Working changes",
+			Expected: "trunk - 2 \uF044 +2 ~3",
+			Template: "{{ .Branch }} - {{ .BaseRev }}{{ if .Working.Changed }} \uF044 {{ .Working.String }}{{ end }}",
+			Svn: &Svn{
+				Branch:  "trunk",
+				BaseRev: 2,
+				Working: &SvnStatus{
+					ScmStatus: ScmStatus{
+						Added:    2,
+						Modified: 3,
+					},
+				},
+			},
+		},
+		{
+			Case:     "Working and staging area changes with separator and stash count",
+			Expected: "trunk CONFLICTED \uF044 +2 ~3 !7",
+			Template: "{{ .Branch }}{{ if .Working.HasConflicts }} CONFLICTED{{ end }}{{ if .Working.Changed }} \uF044 {{ .Working.String }}{{ end }}",
+			Svn: &Svn{
+				Branch:  "trunk",
+				BaseRev: 2,
+				Working: &SvnStatus{
+					ScmStatus: ScmStatus{
+						Added:      2,
+						Modified:   3,
+						Conflicted: 7,
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		props := properties.Map{
+			FetchStatus: true,
+		}
+		env := new(mock.MockedEnvironment)
+		tc.Svn.env = env
+		tc.Svn.props = props
+		assert.Equal(t, tc.Expected, renderTemplate(env, tc.Template, tc.Svn), tc.Case)
+	}
+}
+
+func TestSetSvnStatus(t *testing.T) {
+	cases := []struct {
+		Case              string
+		StatusOutput      string
+		RefOutput         string
+		BranchOutput      string
+		ExpectedWorking   *SvnStatus
+		ExpectedBranch    string
+		ExpectedRef       int
+		ExpectedConflicts bool
+		ExpectedChanged   bool
+	}{
+		{
+			Case: "changed",
+			StatusOutput: `
+!       Untracked.File
+A       FileHasBeen.Added
+D       FileMarkedAs.Deleted
+M       Modified.File
+R       Moved.File`,
+			ExpectedWorking: &SvnStatus{ScmStatus: ScmStatus{
+				Modified: 1,
+				Added:    1,
+				Deleted:  1,
+				Unmerged: 1,
+				Moved:    1,
+			}},
+			RefOutput:       "1133",
+			ExpectedRef:     1133,
+			BranchOutput:    "^/trunk",
+			ExpectedBranch:  "trunk",
+			ExpectedChanged: true,
+		},
+		{
+			Case:         "conflict",
+			StatusOutput: `C       build.cake`,
+			ExpectedWorking: &SvnStatus{ScmStatus: ScmStatus{
+				Conflicted: 1,
+			}},
+			ExpectedChanged:   true,
+			ExpectedConflicts: true,
+		},
+		{
+			Case:            "no change",
+			ExpectedWorking: &SvnStatus{ScmStatus: ScmStatus{}},
+			ExpectedChanged: false,
+		},
+		{
+			Case:            "not an integer ref",
+			ExpectedWorking: &SvnStatus{ScmStatus: ScmStatus{}},
+			ExpectedChanged: false,
+			RefOutput:       "not an integer",
+		},
+	}
+	for _, tc := range cases {
+		fileInfo := &environment.FileInfo{
+			Path:         "/dir/hello",
+			ParentFolder: "/dir",
+			IsDir:        true,
+		}
+		env := new(mock.MockedEnvironment)
+		env.On("InWSLSharedDrive").Return(false)
+		env.On("IsWsl").Return(false)
+		env.On("HasCommand", "svn").Return(true)
+		env.On("GOOS").Return("")
+		env.On("FileContent", "/dir/hello/trunk").Return("")
+		env.MockSvnCommand(fileInfo.Path, "", "info", "--tags", "--exact-match")
+		env.On("HasParentFilePath", ".svn").Return(fileInfo, nil)
+		env.On("RunCommand", "svn", []string{"info", "", "--show-item", "revision"}).Return(tc.RefOutput, nil)
+		env.On("RunCommand", "svn", []string{"info", "", "--show-item", "relative-url"}).Return(tc.BranchOutput, nil)
+		env.On("RunCommand", "svn", []string{"status", ""}).Return(tc.StatusOutput, nil)
+
+		s := &Svn{
+			scm: scm{
+				env:   env,
+				props: properties.Map{},
+			},
+		}
+		s.setSvnStatus()
+		if tc.ExpectedWorking == nil {
+			tc.ExpectedWorking = &SvnStatus{}
+		}
+		assert.Equal(t, tc.ExpectedWorking, s.Working, tc.Case)
+		assert.Equal(t, tc.ExpectedRef, s.BaseRev, tc.Case)
+		assert.Equal(t, tc.ExpectedBranch, s.Branch, tc.Case)
+		assert.Equal(t, tc.ExpectedChanged, s.Working.Changed(), tc.Case)
+		assert.Equal(t, tc.ExpectedConflicts, s.Working.HasConflicts(), tc.Case)
+	}
+}