Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ jobs:
name: msix-artifact-${{ matrix.arch }}
path: |
packages/msix/out/install-${{ matrix.arch }}.msix
packages/msix/out/install-${{ matrix.arch }}.appinstaller
release:
runs-on: ubuntu-latest
needs:
Expand All @@ -149,7 +150,9 @@ jobs:
run: |
echo v${{ needs.changelog.outputs.version }} > version.txt
az storage blob upload-batch --destination releases/v${{ needs.changelog.outputs.version }} --source .
az storage blob upload-batch --destination releases/v${{ needs.changelog.outputs.version }} --overwrite true --source . --pattern "*.appinstaller" --content-type "application/appinstaller"
az storage blob upload-batch --destination releases/latest --overwrite true --source .
az storage blob upload-batch --destination releases/latest --overwrite true --source . --pattern "*.appinstaller" --content-type "application/appinstaller"
- name: Create draft release 📝
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228
with:
Expand Down
14 changes: 13 additions & 1 deletion packages/msix/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,24 @@ the repository's `dist` folder.
./build.ps1 -Architecture x64 -Version "1.3.37"
```

The package is created at `out/install-x64.msix`.
The package is created at `out/install-x64.msix`, along with an App Installer
file at `out/install-x64.appinstaller`.

The `.appinstaller` file references the published package on the CDN and enables
automatic updates: Windows re-checks the stable URL it was installed from
(daily on launch and via a background task) and updates the package silently
when a new version is available.

## Install the package

```powershell
Add-AppxPackage -Path ./out/install-x64.msix
```

## Install via App Installer (with auto-update)

```powershell
Add-AppxPackage -AppInstallerFile https://cdn.ohmyposh.dev/releases/latest/install-x64.appinstaller
```

[Windows SDK]: https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/
41 changes: 39 additions & 2 deletions packages/msix/build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
.PARAMETER Copy
When specified, copies the appropriate executable from the dist folder before packaging.

.PARAMETER DownloadUrl
The base URL where releases are published, used in the generated App Installer file.
Defaults to "https://cdn.ohmyposh.dev/releases".

.EXAMPLE
.\build.ps1 -Architecture x64 -Version "1.2.3" -Copy

Expand All @@ -32,7 +36,7 @@
Creates and signs the MSIX package for arm64 architecture with version 1.2.3.

.OUTPUTS
Creates install-{Architecture}.msix in the 'out' directory.
Creates install-{Architecture}.msix and install-{Architecture}.appinstaller in the 'out' directory.

.NOTES
Requires the Windows SDK for MSIX packaging and signing.
Expand All @@ -56,7 +60,11 @@ param(
[switch]$Sign,

[Parameter()]
[switch]$Copy
[switch]$Copy,

[Parameter()]
[ValidateNotNullOrEmpty()]
[string]$DownloadUrl = "https://cdn.ohmyposh.dev/releases"
)

# Set error handling preferences
Expand Down Expand Up @@ -226,6 +234,35 @@ catch {
throw
}

Write-Verbose "Creating App Installer file" -Verbose

try {
$appInstallerPath = "$currentPath/out/install-$Architecture.appinstaller"
$identity = $manifestDocument.Package.Identity

# The App Installer file lives at a stable URL so Windows can poll it for updates,
# while the package URL is immutable per version to avoid mid-publish races.
$appInstaller = @"
<?xml version="1.0" encoding="utf-8"?>
<AppInstaller xmlns="http://schemas.microsoft.com/appx/appinstaller/2018" Version="$Version.0" Uri="$DownloadUrl/latest/install-$Architecture.appinstaller">
<MainPackage Name="$($identity.Name)" Version="$Version.0" Publisher="$($identity.Publisher)" ProcessorArchitecture="$Architecture" Uri="$DownloadUrl/v$Version/install-$Architecture.msix" />
<UpdateSettings>
<OnLaunch HoursBetweenUpdateChecks="24" />
<AutomaticBackgroundTask />
<ForceUpdateFromAnyVersion>true</ForceUpdateFromAnyVersion>
</UpdateSettings>
</AppInstaller>
"@

$appInstaller | Out-File -FilePath $appInstallerPath -Encoding UTF8

Write-Verbose "Created App Installer file: $appInstallerPath" -Verbose
}
catch {
Write-Error "Failed to create App Installer file: ${_}"
throw
}

if ($Sign) {
$signingTools = Initialize-SigningEnvironment -SDKVersion $SDKVersion
Invoke-PackageSigning -PackagePath $msixPackagePath -SigningTools $signingTools
Expand Down
3 changes: 1 addition & 2 deletions src/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ require (
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.12.0
github.com/stretchr/testify v1.12.1
github.com/wayneashleyberry/terminal-dimensions v1.1.0
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/sys v0.47.0
Expand Down Expand Up @@ -60,5 +60,4 @@ require (
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
golang.org/x/net v0.56.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
6 changes: 2 additions & 4 deletions src/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
Expand Down Expand Up @@ -134,5 +134,3 @@ gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
122 changes: 91 additions & 31 deletions src/segments/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,14 +347,14 @@ func (g *Git) shouldDisplay() bool {
return false
}

if g.options.Bool(FetchBareInfo, false) {
g.IsBare = g.isBareRepo(gitdir)
}

if !g.hasCommand(GITCOMMAND) {
return false
}

if g.options.Bool(FetchBareInfo, false) {
g.IsBare = g.isBareRepo(gitdir)
}

return g.isRepo(gitdir)
}

Expand Down Expand Up @@ -402,7 +402,7 @@ func (g *Git) isBareRepo(gitDir *runtime.FileInfo) bool {
} else {
content := g.fileContent(gitDir.ParentFolder, ".git")
dir := strings.TrimPrefix(content, "gitdir: ")
g.mainSCMDir = filepath.Join(gitDir.ParentFolder, dir)
g.mainSCMDir = resolveGitPath(gitDir.ParentFolder, g.convertToLinuxPath(dir))
}

cfg, err := g.getGitConfig()
Expand Down Expand Up @@ -458,31 +458,33 @@ func (g *Git) hasWorktree(gitdir *runtime.FileInfo) bool {
return false
}

// if we open a worktree file in a WSL shared folder, we have to convert it back
// to the mounted path
g.mainSCMDir = g.convertToLinuxPath(matches["dir"])
// Convert before resolving because filepath.IsAbs("C:/repo/.git") is false on Linux.
raw := g.convertToLinuxPath(matches["dir"])
g.mainSCMDir = resolveGitPath(gitdir.ParentFolder, raw)

// in worktrees, the path looks like this: gitdir: path/.git/worktrees/branch
// scmDir needs to become path/.git
// repoRootDir needs to become path
worktreeIndex := strings.LastIndex(g.mainSCMDir, "/worktrees/")
// The returned index only applies to the normalized path.
adminDir := filepath.ToSlash(filepath.Clean(g.mainSCMDir))
worktreeIndex := worktreeAdminIndex(adminDir)

// 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
if strings.Contains(g.mainSCMDir, "/modules/") {
g.scmDir = resolveGitPath(gitdir.ParentFolder, g.mainSCMDir)
// we need the parent folder to detect where the real .git folder is. Test raw rather
// than the resolved path: a checkout below a folder named modules would otherwise
// drag every genuine worktree in it into this branch.
if strings.Contains(raw, "/modules/") && g.isModuleAdminDir(g.mainSCMDir, gitdir.ParentFolder) {
g.scmDir = g.mainSCMDir
// this might be both a worktree and a submodule, where the path would look like
// this: path/.git/modules/module/path/worktrees/location. We cannot distinguish
// between worktree and a module path containing the word 'worktree,' however.
worktreeIndex = strings.LastIndex(g.scmDir, "/worktrees/")
moduleDir := filepath.ToSlash(filepath.Clean(g.scmDir))
worktreeIndex = worktreeAdminIndex(moduleDir)
if worktreeIndex > -1 && g.env.HasFilesInDir(g.scmDir, "gitdir") {
gitDir := filepath.Join(g.scmDir, "gitdir")
realGitFolder := g.env.FileContent(gitDir)
g.repoRootDir = strings.TrimSuffix(strings.TrimRight(realGitFolder, "\n\r "), ".git")
g.repoRootDir = g.convertToLinuxPath(g.repoRootDir)
// resolve relative paths (worktree.useRelativePaths = true)
g.repoRootDir = resolveGitPath(g.scmDir, g.repoRootDir)
g.scmDir = g.scmDir[:worktreeIndex]
g.scmDir = moduleDir[:worktreeIndex]
g.mainSCMDir = g.scmDir
g.IsWorkTree = true
return true
Expand All @@ -493,22 +495,21 @@ func (g *Git) hasWorktree(gitdir *runtime.FileInfo) bool {
return true
}

// convert to absolute path for worktrees only
if strings.HasPrefix(g.mainSCMDir, "..") {
g.mainSCMDir = resolveGitPath(gitdir.ParentFolder, g.mainSCMDir)
worktreeIndex = strings.LastIndex(g.mainSCMDir, "/worktrees/")
}

if worktreeIndex > -1 {
gitDir := filepath.Join(g.mainSCMDir, "gitdir")
g.scmDir = g.mainSCMDir[:worktreeIndex]
gitDirContent := g.env.FileContent(gitDir)
g.repoRootDir = strings.TrimSuffix(strings.TrimRight(gitDirContent, "\n\r "), ".git")
g.repoRootDir = g.convertToLinuxPath(g.repoRootDir)
gitDirContent := g.env.FileContent(filepath.Join(g.mainSCMDir, "gitdir"))
gitDirPath := strings.TrimRight(gitDirContent, "\n\r ")
root := strings.TrimSuffix(gitDirPath, ".git")
root = g.convertToLinuxPath(root)
// resolve relative paths (worktree.useRelativePaths = true)
g.repoRootDir = resolveGitPath(g.mainSCMDir, g.repoRootDir)
g.IsWorkTree = true
return true
root = resolveGitPath(g.mainSCMDir, root)

// A genuine worktree's metadata points back at the .git file we just read.
if gitDirPath != "" && filepath.Clean(root) == filepath.Clean(gitdir.ParentFolder) {
g.scmDir = adminDir[:worktreeIndex]
g.repoRootDir = root
g.IsWorkTree = true
return true
}
}

// check for separate git folder(--separate-git-dir)
Expand Down Expand Up @@ -1196,6 +1197,65 @@ func (g *Git) commonGitDir() string {
return filepath.ToSlash(g.scmDir)
}

// isModuleAdminDir reports whether target is a submodule administrative directory
// belonging to the checkout at parent, or a linked worktree inside one.
//
// A pointer whose spelling merely contains a modules component is not enough: a
// --separate-git-dir target such as /srv/modules/project.git spells the same substring
// without being a submodule. Nor does the shape help the way it does for worktrees. A
// submodule's name is its path, so it may hold separators (.git/modules/vendor/libfoo),
// it may nest (.../modules/vendor/libfoo/modules/inner), and the folder in front of
// modules is only called .git when the superproject has no --separate-git-dir of its own
// (../../sepgit/modules/sub is a real pointer).
//
// What does hold is that git records core.worktree in a submodule's git dir, pointing
// back at the checkout, and never records it in a --separate-git-dir target. That
// back-reference is the same kind of proof the worktree branch takes from its gitdir
// metadata.
func (g *Git) isModuleAdminDir(target, parent string) bool {
// A linked worktree inside a module dir keeps no config of its own, so it cannot
// carry core.worktree. Its gitdir metadata identifies it instead, which is what the
// worktree case in the caller goes on to validate.
if worktreeAdminIndex(target) > -1 && g.env.HasFilesInDir(target, "gitdir") {
return true
}

cfg, err := ini.Load(g.fileContent(target, "config"))
if err != nil {
log.Error(err)
return false
}

worktree := cfg.Section("core").Key("worktree").String()
if worktree == "" {
log.Debug("no core.worktree in", target, "- not a submodule git dir")
return false
}

// core.worktree is relative to the git dir holding it, and may need the same WSL
// conversion as the pointer itself.
root := resolveGitPath(target, g.convertToLinuxPath(worktree))

return filepath.Clean(root) == filepath.Clean(parent)
}

func worktreeAdminIndex(dir string) int {
const segment = "/worktrees/"

normalised := filepath.ToSlash(filepath.Clean(dir))
index := strings.LastIndex(normalised, segment)
if index < 0 {
return -1
}

name := normalised[index+len(segment):]
if name == "" || strings.Contains(name, "/") {
return -1
}

return index
}

func parseMainWorktree(output string) (string, bool) {
// Git guarantees the main worktree is the first record.
record, _, found := strings.Cut(output, "\x00\x00")
Expand Down
Loading
Loading