mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-23 19:54:02 -04:00
feat(plugins): add portable plugin lockfile (#2976)
* feat(plugins): add portable plugin lockfile * plugins/lockfile: allow ssh remotes --------- Co-authored-by: bbedward <[email protected]>
This commit is contained in:
1 parent
a575363f89
commit
b7ec57a534
12 files changed
+1171
-93
No files matched your search
@@ -101,7 +101,9 @@ MPRIS player controls, calendar sync, weather widgets, and clipboard history wit
|
||||
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dank-greeter](https://github.com/AvengeMedia/dank-greeter).
|
||||
|
||||
**Plugin System**
|
||||
Extend functionality with the [plugin registry](https://plugins.danklinux.com).
|
||||
Extend functionality with the [plugin registry](https://plugins.danklinux.com). DMS keeps
|
||||
`~/.config/DankMaterialShell/plugins.lock.json` synchronized with managed plugin installs and
|
||||
their exact Git commits, so the same plugins can be reproduced on another machine.
|
||||
|
||||
## Supported Compositors
|
||||
|
||||
@@ -120,6 +122,8 @@ dms ipc call audio setvolume 50
|
||||
dms ipc call wallpaper set /path/to/image.jpg
|
||||
dms brightness list # List available displays
|
||||
dms plugins search # Browse plugin registry
|
||||
dms plugins lock # Refresh the portable plugin lockfile
|
||||
dms plugins restore ~/plugins.lock.json
|
||||
```
|
||||
|
||||
[Full CLI and IPC documentation](https://danklinux.com/docs/dankmaterialshell/keybinds-ipc)
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ Custom IPC via unix socket (JSON API) for shell communication.
|
||||
- `dms run [-d]` - Start shell (optionally as daemon)
|
||||
- `dms restart` / `dms kill` - Manage running processes
|
||||
- `dms ipc <command>` - Send IPC commands (toggle launcher, notifications, etc.)
|
||||
- `dms plugins [install|browse|search]` - Plugin management
|
||||
- `dms plugins [install|browse|search|lock|restore]` - Plugin management and portable exact-revision lockfiles
|
||||
- `dms brightness [list|set]` - Control display/monitor brightness
|
||||
- `dms color pick` - Native color picker (see below)
|
||||
- `dms update` - Update DMS and dependencies (disabled in distro packages)
|
||||
|
||||
@@ -51,6 +51,8 @@ func init() {
|
||||
})
|
||||
pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins")
|
||||
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
|
||||
pluginsLockCmd.Flags().StringP("output", "o", "", "Also write the lockfile to this path")
|
||||
pluginsRestoreCmd.Flags().Bool("prune", false, "Remove managed plugins that are not in the lockfile")
|
||||
}
|
||||
|
||||
var debugSrvCmd = &cobra.Command{
|
||||
@@ -178,6 +180,36 @@ var pluginsUpdateCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var pluginsLockCmd = &cobra.Command{
|
||||
Use: "lock",
|
||||
Short: "Record installed plugins and their exact revisions",
|
||||
Long: "Write a portable plugins.lock.json containing every managed user plugin and its current Git commit.",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if err := lockPluginsCLI(output); err != nil {
|
||||
log.Fatalf("Error writing plugin lockfile: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var pluginsRestoreCmd = &cobra.Command{
|
||||
Use: "restore [lockfile]",
|
||||
Short: "Restore plugins from exact revisions in a lockfile",
|
||||
Long: "Install or reset all plugins in a portable plugins.lock.json. Existing managed plugins are retained unless --prune is specified.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
path := ""
|
||||
if len(args) == 1 {
|
||||
path = args[0]
|
||||
}
|
||||
prune, _ := cmd.Flags().GetBool("prune")
|
||||
if err := restorePluginsCLI(path, prune); err != nil {
|
||||
log.Fatalf("Error restoring plugins: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func runVersion(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("%s\n", formatVersion(Version))
|
||||
}
|
||||
@@ -433,6 +465,40 @@ func installPluginCLI(idOrName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func lockPluginsCLI(outputPath string) error {
|
||||
manager, err := plugins.NewManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create manager: %w", err)
|
||||
}
|
||||
warnings, err := manager.WriteCurrentLockfile(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, warning := range warnings {
|
||||
fmt.Fprintf(os.Stderr, "Warning: %s\n", warning)
|
||||
}
|
||||
fmt.Printf("Plugin lockfile written: %s\n", manager.GetLockfilePath())
|
||||
if outputPath != "" && outputPath != manager.GetLockfilePath() {
|
||||
fmt.Printf("Plugin lockfile exported: %s\n", outputPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func restorePluginsCLI(path string, prune bool) error {
|
||||
manager, err := plugins.NewManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create manager: %w", err)
|
||||
}
|
||||
if err := manager.RestoreFromLockfile(path, prune); err != nil {
|
||||
return err
|
||||
}
|
||||
if path == "" {
|
||||
path = manager.GetLockfilePath()
|
||||
}
|
||||
fmt.Printf("Plugins restored from lockfile: %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAvailablePluginIDs() []string {
|
||||
registry, err := plugins.NewRegistry()
|
||||
if err != nil {
|
||||
|
||||
@@ -15,7 +15,7 @@ func init() {
|
||||
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
updateCmd.AddCommand(updateCheckCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd, pluginsLockCmd, pluginsRestoreCmd)
|
||||
registryCmd.AddCommand(registryListCmd, registryAddCmd, registryRemoveCmd)
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ var Version = "dev"
|
||||
func init() {
|
||||
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd, pluginsLockCmd, pluginsRestoreCmd)
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
rootCmd.AddCommand(authCmd)
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const pluginLockfileVersion = 1
|
||||
|
||||
var gitCommitPattern = regexp.MustCompile(`^[0-9a-fA-F]{40}$`)
|
||||
|
||||
var scpLikeRepoPattern = regexp.MustCompile(`^[^@:/\s]+@[^@:/\s]+:\S+$`)
|
||||
|
||||
type PluginLockfile struct {
|
||||
LockfileVersion int `json:"lockfileVersion"`
|
||||
Plugins map[string]LockedPlugin `json:"plugins"`
|
||||
}
|
||||
|
||||
type LockedPlugin struct {
|
||||
Repo string `json:"repo"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
type LockStore struct {
|
||||
fs afero.Fs
|
||||
path string
|
||||
}
|
||||
|
||||
func NewLockStore(fs afero.Fs, path string) *LockStore {
|
||||
return &LockStore{fs: fs, path: path}
|
||||
}
|
||||
|
||||
func NewPluginLockfile() PluginLockfile {
|
||||
return PluginLockfile{
|
||||
LockfileVersion: pluginLockfileVersion,
|
||||
Plugins: make(map[string]LockedPlugin),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LockStore) Path() string {
|
||||
return s.path
|
||||
}
|
||||
|
||||
func (s *LockStore) Exists() (bool, error) {
|
||||
return afero.Exists(s.fs, s.path)
|
||||
}
|
||||
|
||||
func (s *LockStore) Load() (PluginLockfile, error) {
|
||||
data, err := afero.ReadFile(s.fs, s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return NewPluginLockfile(), nil
|
||||
}
|
||||
return PluginLockfile{}, fmt.Errorf("failed to read plugin lockfile: %w", err)
|
||||
}
|
||||
|
||||
return ParsePluginLockfile(data)
|
||||
}
|
||||
|
||||
func ParsePluginLockfile(data []byte) (PluginLockfile, error) {
|
||||
var lock PluginLockfile
|
||||
if err := json.Unmarshal(data, &lock); err != nil {
|
||||
return PluginLockfile{}, fmt.Errorf("failed to parse plugin lockfile: %w", err)
|
||||
}
|
||||
if lock.Plugins == nil {
|
||||
lock.Plugins = make(map[string]LockedPlugin)
|
||||
}
|
||||
if err := lock.Validate(); err != nil {
|
||||
return PluginLockfile{}, err
|
||||
}
|
||||
return lock, nil
|
||||
}
|
||||
|
||||
func (s *LockStore) Write(lock PluginLockfile) error {
|
||||
if err := lock.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(lock, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode plugin lockfile: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
if err := s.fs.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create plugin lockfile directory: %w", err)
|
||||
}
|
||||
|
||||
tmpPath := s.path + ".tmp"
|
||||
if err := afero.WriteFile(s.fs, tmpPath, data, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write temporary plugin lockfile: %w", err)
|
||||
}
|
||||
defer s.fs.Remove(tmpPath) //nolint:errcheck
|
||||
|
||||
if err := s.fs.Rename(tmpPath, s.path); err != nil {
|
||||
return fmt.Errorf("failed to replace plugin lockfile: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lock PluginLockfile) Validate() error {
|
||||
if lock.LockfileVersion != pluginLockfileVersion {
|
||||
return fmt.Errorf("unsupported plugin lockfile version %d (expected %d)", lock.LockfileVersion, pluginLockfileVersion)
|
||||
}
|
||||
|
||||
repoCommits := make(map[string]string)
|
||||
for id, plugin := range lock.Plugins {
|
||||
if !isSafePluginPathComponent(id) {
|
||||
return fmt.Errorf("invalid locked plugin id: %q", id)
|
||||
}
|
||||
if strings.TrimSpace(plugin.Repo) == "" {
|
||||
return fmt.Errorf("locked plugin %q has no repository", id)
|
||||
}
|
||||
if err := validatePluginRepo(plugin.Repo); err != nil {
|
||||
return fmt.Errorf("locked plugin %q: %w", id, err)
|
||||
}
|
||||
if !gitCommitPattern.MatchString(plugin.Commit) {
|
||||
return fmt.Errorf("locked plugin %q has invalid commit %q", id, plugin.Commit)
|
||||
}
|
||||
if err := validatePluginRepoPath(plugin.Path); err != nil {
|
||||
return fmt.Errorf("locked plugin %q: %w", id, err)
|
||||
}
|
||||
if commit, ok := repoCommits[plugin.Repo]; ok && !strings.EqualFold(commit, plugin.Commit) {
|
||||
return fmt.Errorf("plugins from repository %q must use the same commit", plugin.Repo)
|
||||
}
|
||||
repoCommits[plugin.Repo] = plugin.Commit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePluginRepo(repo string) error {
|
||||
parsed, err := url.Parse(repo)
|
||||
if err != nil {
|
||||
if scpLikeRepoPattern.MatchString(repo) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid repository URL %q", repo)
|
||||
}
|
||||
if parsed.User == nil {
|
||||
return nil
|
||||
}
|
||||
if _, hasPassword := parsed.User.Password(); hasPassword {
|
||||
return fmt.Errorf("repository URL must not contain credentials")
|
||||
}
|
||||
switch parsed.Scheme {
|
||||
case "http", "https":
|
||||
return fmt.Errorf("repository URL must not contain credentials")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePluginRepoPath(path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
clean := filepath.Clean(path)
|
||||
if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("invalid repository path %q", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lock PluginLockfile) IDs() []string {
|
||||
ids := make([]string, 0, len(lock.Plugins))
|
||||
for id := range lock.Plugins {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func (lock *PluginLockfile) SetRepositoryCommit(repo, commit string) {
|
||||
for id, plugin := range lock.Plugins {
|
||||
if plugin.Repo != repo {
|
||||
continue
|
||||
}
|
||||
plugin.Commit = commit
|
||||
lock.Plugins[id] = plugin
|
||||
}
|
||||
}
|
||||
|
||||
func (lock PluginLockfile) Clone() PluginLockfile {
|
||||
cloned := NewPluginLockfile()
|
||||
for id, plugin := range lock.Plugins {
|
||||
cloned.Plugins[id] = plugin
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testCommit = "0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
func TestPluginLockfileRoundTrip(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
store := NewLockStore(fs, "/config/plugins.lock.json")
|
||||
lock := NewPluginLockfile()
|
||||
lock.Plugins["weather"] = LockedPlugin{
|
||||
Repo: "https://github.com/example/plugins.git",
|
||||
Path: "plugins/weather",
|
||||
Commit: testCommit,
|
||||
}
|
||||
|
||||
require.NoError(t, store.Write(lock))
|
||||
loaded, err := store.Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, lock, loaded)
|
||||
|
||||
data, err := afero.ReadFile(fs, store.Path())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "{\n \"lockfileVersion\": 1,\n \"plugins\": {\n \"weather\": {\n \"repo\": \"https://github.com/example/plugins.git\",\n \"path\": \"plugins/weather\",\n \"commit\": \"0123456789abcdef0123456789abcdef01234567\"\n }\n }\n}\n", string(data))
|
||||
}
|
||||
|
||||
func TestPluginLockfileValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lock PluginLockfile
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "rejects unsupported version",
|
||||
lock: PluginLockfile{LockfileVersion: 2, Plugins: map[string]LockedPlugin{}},
|
||||
want: "unsupported plugin lockfile version",
|
||||
},
|
||||
{
|
||||
name: "rejects unsafe id",
|
||||
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"../bad": {Repo: "repo", Commit: testCommit}}},
|
||||
want: "invalid locked plugin id",
|
||||
},
|
||||
{
|
||||
name: "rejects unsafe repository path",
|
||||
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"bad": {Repo: "repo", Path: "../bad", Commit: testCommit}}},
|
||||
want: "invalid repository path",
|
||||
},
|
||||
{
|
||||
name: "rejects abbreviated commit",
|
||||
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"bad": {Repo: "repo", Commit: "abc123"}}},
|
||||
want: "invalid commit",
|
||||
},
|
||||
{
|
||||
name: "rejects repository credentials",
|
||||
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"bad": {Repo: "https://[email protected]/plugin.git", Commit: testCommit}}},
|
||||
want: "must not contain credentials",
|
||||
},
|
||||
{
|
||||
name: "rejects ssh password",
|
||||
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"bad": {Repo: "ssh://git:[email protected]/plugin.git", Commit: testCommit}}},
|
||||
want: "must not contain credentials",
|
||||
},
|
||||
{
|
||||
name: "rejects conflicting monorepo commits",
|
||||
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{
|
||||
"one": {Repo: "repo", Commit: testCommit},
|
||||
"two": {Repo: "repo", Commit: "1123456789abcdef0123456789abcdef01234567"},
|
||||
}},
|
||||
want: "must use the same commit",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.lock.Validate()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginLockfileAcceptsSSHRemotes(t *testing.T) {
|
||||
repos := []string{
|
||||
"https://github.com/user/plugin.git",
|
||||
"ssh://[email protected]/user/plugin.git",
|
||||
"[email protected]:user/plugin.git",
|
||||
}
|
||||
|
||||
for _, repo := range repos {
|
||||
t.Run(repo, func(t *testing.T) {
|
||||
lock := PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{
|
||||
"plugin": {Repo: repo, Commit: testCommit},
|
||||
}}
|
||||
assert.NoError(t, lock.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingPluginLockfileIsEmpty(t *testing.T) {
|
||||
store := NewLockStore(afero.NewMemMapFs(), "/config/plugins.lock.json")
|
||||
lock, err := store.Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pluginLockfileVersion, lock.LockfileVersion)
|
||||
assert.Empty(t, lock.Plugins)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
type Manager struct {
|
||||
fs afero.Fs
|
||||
pluginsDir string
|
||||
lockPath string
|
||||
gitClient GitClient
|
||||
}
|
||||
|
||||
@@ -28,6 +29,7 @@ func NewManagerWithFs(fs afero.Fs) (*Manager, error) {
|
||||
return &Manager{
|
||||
fs: fs,
|
||||
pluginsDir: pluginsDir,
|
||||
lockPath: getPluginLockPath(),
|
||||
gitClient: &realGitClient{},
|
||||
}, nil
|
||||
}
|
||||
@@ -41,6 +43,15 @@ func getPluginsDir() string {
|
||||
return filepath.Join(configDir, "DankMaterialShell", "plugins")
|
||||
}
|
||||
|
||||
func getPluginLockPath() string {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
log.Error("failed to get user config dir", "err", err)
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(configDir, "DankMaterialShell", "plugins.lock.json")
|
||||
}
|
||||
|
||||
func (m *Manager) IsInstalled(plugin Plugin) (bool, error) {
|
||||
path, err := m.findInstalledPath(plugin.ID)
|
||||
if err != nil {
|
||||
@@ -118,7 +129,12 @@ func (m *Manager) findInDir(dir, pluginID string) (string, error) {
|
||||
}
|
||||
|
||||
func (m *Manager) Install(plugin Plugin) error {
|
||||
if !isSafePluginPathComponent(plugin.ID) {
|
||||
return fmt.Errorf("invalid plugin id: %q", plugin.ID)
|
||||
}
|
||||
|
||||
pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
|
||||
repoPath := pluginPath
|
||||
|
||||
exists, err := afero.DirExists(m.fs, pluginPath)
|
||||
if err != nil {
|
||||
@@ -128,6 +144,18 @@ func (m *Manager) Install(plugin Plugin) error {
|
||||
if exists {
|
||||
return fmt.Errorf("plugin already installed: %s", plugin.Name)
|
||||
}
|
||||
if strings.TrimSpace(plugin.Repo) == "" {
|
||||
return fmt.Errorf("plugin repository is required")
|
||||
}
|
||||
if err := validatePluginRepo(plugin.Repo); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePluginRepoPath(plugin.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := m.ensureLockfile(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.fs.MkdirAll(m.pluginsDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create plugins directory: %w", err)
|
||||
@@ -140,7 +168,7 @@ func (m *Manager) Install(plugin Plugin) error {
|
||||
|
||||
if plugin.Path != "" {
|
||||
repoName := m.getRepoName(plugin.Repo)
|
||||
repoPath := filepath.Join(reposDir, repoName)
|
||||
repoPath = filepath.Join(reposDir, repoName)
|
||||
|
||||
repoExists, err := afero.DirExists(m.fs, repoPath)
|
||||
if err != nil {
|
||||
@@ -178,12 +206,13 @@ func (m *Manager) Install(plugin Plugin) error {
|
||||
if err := m.createSymlink(sourcePath, pluginPath); err != nil {
|
||||
return fmt.Errorf("failed to create symlink: %w", err)
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaContent := fmt.Sprintf("repo=%s\npath=%s\nrepodir=%s", plugin.Repo, plugin.Path, repoName)
|
||||
if err := afero.WriteFile(m.fs, metaPath, []byte(metaContent), 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write metadata: %w", err)
|
||||
m.fs.Remove(pluginPath) //nolint:errcheck
|
||||
return fmt.Errorf("failed to write plugin repository metadata: %w", err)
|
||||
}
|
||||
|
||||
} else {
|
||||
if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
|
||||
m.fs.RemoveAll(pluginPath) //nolint:errcheck
|
||||
@@ -191,6 +220,16 @@ func (m *Manager) Install(plugin Plugin) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.recordInstalledPlugin(plugin, repoPath); err != nil {
|
||||
if plugin.Path != "" {
|
||||
m.fs.Remove(pluginPath) //nolint:errcheck
|
||||
m.fs.Remove(pluginPath + ".meta") //nolint:errcheck
|
||||
} else {
|
||||
m.fs.RemoveAll(pluginPath) //nolint:errcheck
|
||||
}
|
||||
return fmt.Errorf("failed to update plugin lockfile: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -207,6 +246,15 @@ func (m *Manager) createSymlink(source, dest string) error {
|
||||
}
|
||||
|
||||
func (m *Manager) Update(plugin Plugin) error {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locked, ok := lock.Plugins[plugin.ID]; ok {
|
||||
plugin.Repo = locked.Repo
|
||||
plugin.Path = locked.Path
|
||||
}
|
||||
|
||||
pluginPath, err := m.findInstalledPath(plugin.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find plugin: %w", err)
|
||||
@@ -219,46 +267,42 @@ func (m *Manager) Update(plugin Plugin) error {
|
||||
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||
return fmt.Errorf("cannot update system plugin: %s", plugin.Name)
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaExists, err := afero.Exists(m.fs, metaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check metadata: %w", err)
|
||||
}
|
||||
|
||||
if metaExists {
|
||||
reposDir := filepath.Join(m.pluginsDir, ".repos")
|
||||
repoName := m.getRepoName(plugin.Repo)
|
||||
repoPath := filepath.Join(reposDir, repoName)
|
||||
|
||||
// Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone
|
||||
if err := m.gitClient.Pull(repoPath); err != nil {
|
||||
// Repository is likely corrupted or has issues, delete and re-clone
|
||||
if err := m.fs.RemoveAll(repoPath); err != nil {
|
||||
return fmt.Errorf("failed to remove corrupted repository: %w", err)
|
||||
}
|
||||
|
||||
if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
|
||||
return fmt.Errorf("failed to re-clone repository: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Try to pull, if it fails, delete and re-clone
|
||||
if err := m.gitClient.Pull(pluginPath); err != nil {
|
||||
if err := m.fs.RemoveAll(pluginPath); err != nil {
|
||||
return fmt.Errorf("failed to remove corrupted plugin: %w", err)
|
||||
}
|
||||
|
||||
if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
|
||||
return fmt.Errorf("failed to re-clone plugin: %w", err)
|
||||
}
|
||||
if plugin.Repo == "" {
|
||||
plugin.Repo, err = m.gitClient.OriginURL(pluginPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read plugin origin: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
repoPath := m.repositoryPath(plugin.ID, LockedPlugin{Repo: plugin.Repo, Path: plugin.Path})
|
||||
if err := m.gitClient.Pull(repoPath); err != nil {
|
||||
if err := m.fs.RemoveAll(repoPath); err != nil {
|
||||
return fmt.Errorf("failed to remove corrupted plugin repository: %w", err)
|
||||
}
|
||||
if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
|
||||
return fmt.Errorf("failed to re-clone plugin repository: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.recordInstalledPlugin(plugin, repoPath); err != nil {
|
||||
if locked, ok := lock.Plugins[plugin.ID]; ok {
|
||||
m.gitClient.CheckoutRevision(repoPath, locked.Commit) //nolint:errcheck
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Uninstall(plugin Plugin) error {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locked, ok := lock.Plugins[plugin.ID]; ok {
|
||||
plugin.Repo = locked.Repo
|
||||
plugin.Path = locked.Path
|
||||
}
|
||||
|
||||
pluginPath, err := m.findInstalledPath(plugin.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find plugin: %w", err)
|
||||
@@ -271,70 +315,64 @@ func (m *Manager) Uninstall(plugin Plugin) error {
|
||||
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||
return fmt.Errorf("cannot uninstall system plugin: %s", plugin.Name)
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaExists, err := afero.Exists(m.fs, metaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check metadata: %w", err)
|
||||
updatedLock := lock.Clone()
|
||||
delete(updatedLock.Plugins, plugin.ID)
|
||||
if err := m.lockStore().Write(updatedLock); err != nil {
|
||||
return err
|
||||
}
|
||||
rollbackLock := func(err error) error {
|
||||
if rollbackErr := m.lockStore().Write(lock); rollbackErr != nil {
|
||||
return fmt.Errorf("%w (also failed to restore plugin lockfile: %v)", err, rollbackErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if metaExists {
|
||||
metaPath := pluginPath + ".meta"
|
||||
if plugin.Path != "" {
|
||||
reposDir := filepath.Join(m.pluginsDir, ".repos")
|
||||
repoName := m.getRepoName(plugin.Repo)
|
||||
repoPath := filepath.Join(reposDir, repoName)
|
||||
|
||||
shouldCleanup, err := m.shouldCleanupRepo(repoPath, plugin.Repo, plugin.ID)
|
||||
shouldCleanup, err := m.shouldCleanupRepo(plugin.Repo, plugin.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check repo cleanup: %w", err)
|
||||
return rollbackLock(fmt.Errorf("failed to check repo cleanup: %w", err))
|
||||
}
|
||||
|
||||
if err := m.fs.Remove(pluginPath); err != nil {
|
||||
return fmt.Errorf("failed to remove symlink: %w", err)
|
||||
return rollbackLock(fmt.Errorf("failed to remove symlink: %w", err))
|
||||
}
|
||||
|
||||
if err := m.fs.Remove(metaPath); err != nil {
|
||||
return fmt.Errorf("failed to remove metadata: %w", err)
|
||||
if metaExists, _ := afero.Exists(m.fs, metaPath); metaExists {
|
||||
if err := m.fs.Remove(metaPath); err != nil {
|
||||
return rollbackLock(fmt.Errorf("failed to remove metadata: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if shouldCleanup {
|
||||
if err := m.fs.RemoveAll(repoPath); err != nil {
|
||||
return fmt.Errorf("failed to cleanup repository: %w", err)
|
||||
return rollbackLock(fmt.Errorf("failed to cleanup repository: %w", err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := m.fs.RemoveAll(pluginPath); err != nil {
|
||||
return fmt.Errorf("failed to remove plugin: %w", err)
|
||||
return rollbackLock(fmt.Errorf("failed to remove plugin: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) shouldCleanupRepo(repoPath, repoURL, excludePlugin string) (bool, error) {
|
||||
installed, err := m.ListInstalled()
|
||||
func (m *Manager) shouldCleanupRepo(repoURL, excludePlugin string) (bool, error) {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
registry, err := NewRegistry()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
allPlugins, err := registry.List()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, id := range installed {
|
||||
for id, plugin := range lock.Plugins {
|
||||
if id == excludePlugin {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, p := range allPlugins {
|
||||
if p.ID == id && p.Repo == repoURL && p.Path != "" {
|
||||
return false, nil
|
||||
}
|
||||
if plugin.Repo == repoURL && plugin.Path != "" {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,6 +494,16 @@ func (m *Manager) UninstallByIDOrName(idOrName string) error {
|
||||
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||
return fmt.Errorf("cannot uninstall system plugin: %s", idOrName)
|
||||
}
|
||||
manifest := m.getPluginManifest(pluginPath)
|
||||
if manifest != nil {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locked, ok := lock.Plugins[manifest.ID]; ok {
|
||||
return m.Uninstall(Plugin{ID: manifest.ID, Name: manifest.Name, Repo: locked.Repo, Path: locked.Path})
|
||||
}
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaExists, _ := afero.Exists(m.fs, metaPath)
|
||||
@@ -488,6 +536,16 @@ func (m *Manager) UpdateByIDOrName(idOrName string) error {
|
||||
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||
return fmt.Errorf("cannot update system plugin: %s", idOrName)
|
||||
}
|
||||
manifest := m.getPluginManifest(pluginPath)
|
||||
if manifest != nil {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locked, ok := lock.Plugins[manifest.ID]; ok {
|
||||
return m.Update(Plugin{ID: manifest.ID, Name: manifest.Name, Repo: locked.Repo, Path: locked.Path})
|
||||
}
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaExists, _ := afero.Exists(m.fs, metaPath)
|
||||
@@ -502,8 +560,14 @@ func (m *Manager) UpdateByIDOrName(idOrName string) error {
|
||||
if err := m.gitClient.Pull(pluginPath); err != nil {
|
||||
return fmt.Errorf("failed to update plugin: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
if manifest == nil {
|
||||
return nil
|
||||
}
|
||||
repo, err := m.gitClient.OriginURL(pluginPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read plugin origin: %w", err)
|
||||
}
|
||||
return m.recordInstalledPlugin(Plugin{ID: manifest.ID, Name: manifest.Name, Repo: repo}, pluginPath)
|
||||
}
|
||||
|
||||
func (m *Manager) findInstalledPathByIDOrName(idOrName string) (string, error) {
|
||||
@@ -572,6 +636,15 @@ func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) {
|
||||
}
|
||||
|
||||
func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (hasUpdates bool, diffURL string, err error) {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if locked, ok := lock.Plugins[pluginID]; ok {
|
||||
plugin.Repo = locked.Repo
|
||||
plugin.Path = locked.Path
|
||||
}
|
||||
|
||||
pluginPath, err := m.findInstalledPath(pluginID)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("failed to find plugin: %w", err)
|
||||
@@ -585,25 +658,11 @@ func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (hasUpdates bool, d
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaExists, err := afero.Exists(m.fs, metaPath)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("failed to check metadata: %w", err)
|
||||
}
|
||||
|
||||
var hasUp bool
|
||||
var localHash, remoteHash string
|
||||
if metaExists {
|
||||
// Plugin is from a monorepo, check the repo directory
|
||||
reposDir := filepath.Join(m.pluginsDir, ".repos")
|
||||
repoName := m.getRepoName(plugin.Repo)
|
||||
repoPath := filepath.Join(reposDir, repoName)
|
||||
|
||||
hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(repoPath)
|
||||
} else {
|
||||
// Plugin is a standalone repo
|
||||
hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(pluginPath)
|
||||
repoPath := pluginPath
|
||||
if plugin.Path != "" {
|
||||
repoPath = m.repositoryPath(pluginID, LockedPlugin{Repo: plugin.Repo, Path: plugin.Path})
|
||||
}
|
||||
hasUp, localHash, remoteHash, err := m.gitClient.HasUpdates(repoPath)
|
||||
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
func (m *Manager) lockStore() *LockStore {
|
||||
lockPath := m.lockPath
|
||||
if lockPath == "" {
|
||||
lockPath = filepath.Join(filepath.Dir(m.pluginsDir), "plugins.lock.json")
|
||||
}
|
||||
return NewLockStore(m.fs, lockPath)
|
||||
}
|
||||
|
||||
func (m *Manager) GetLockfilePath() string {
|
||||
return m.lockStore().Path()
|
||||
}
|
||||
|
||||
func (m *Manager) ensureLockfile() (PluginLockfile, error) {
|
||||
store := m.lockStore()
|
||||
exists, err := store.Exists()
|
||||
if err != nil {
|
||||
return PluginLockfile{}, fmt.Errorf("failed to check plugin lockfile: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return store.Load()
|
||||
}
|
||||
|
||||
lock, _, err := m.SnapshotLockfile()
|
||||
if err != nil {
|
||||
return PluginLockfile{}, err
|
||||
}
|
||||
if err := store.Write(lock); err != nil {
|
||||
return PluginLockfile{}, err
|
||||
}
|
||||
return lock, nil
|
||||
}
|
||||
|
||||
func (m *Manager) SnapshotLockfile() (PluginLockfile, []string, error) {
|
||||
lock := NewPluginLockfile()
|
||||
warnings := []string{}
|
||||
|
||||
exists, err := afero.DirExists(m.fs, m.pluginsDir)
|
||||
if err != nil {
|
||||
return PluginLockfile{}, nil, fmt.Errorf("failed to check plugins directory: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return lock, warnings, nil
|
||||
}
|
||||
|
||||
entries, err := afero.ReadDir(m.fs, m.pluginsDir)
|
||||
if err != nil {
|
||||
return PluginLockfile{}, nil, fmt.Errorf("failed to read plugins directory: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if name == ".repos" || strings.HasSuffix(name, ".meta") {
|
||||
continue
|
||||
}
|
||||
|
||||
pluginPath := filepath.Join(m.pluginsDir, name)
|
||||
manifest := m.getPluginManifest(pluginPath)
|
||||
if manifest == nil || manifest.ID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
locked, repoPath, err := m.lockedPluginFromInstall(pluginPath)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("%s: %v", manifest.ID, err))
|
||||
continue
|
||||
}
|
||||
commit, err := m.gitClient.CurrentRevision(repoPath)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("%s: cannot read git revision: %v", manifest.ID, err))
|
||||
continue
|
||||
}
|
||||
locked.Commit = commit
|
||||
lock.SetRepositoryCommit(locked.Repo, commit)
|
||||
lock.Plugins[manifest.ID] = locked
|
||||
}
|
||||
|
||||
if err := lock.Validate(); err != nil {
|
||||
return PluginLockfile{}, warnings, err
|
||||
}
|
||||
return lock, warnings, nil
|
||||
}
|
||||
|
||||
func (m *Manager) lockedPluginFromInstall(pluginPath string) (LockedPlugin, string, error) {
|
||||
metaPath := pluginPath + ".meta"
|
||||
if metaExists, _ := afero.Exists(m.fs, metaPath); metaExists {
|
||||
data, err := afero.ReadFile(m.fs, metaPath)
|
||||
if err != nil {
|
||||
return LockedPlugin{}, "", fmt.Errorf("failed to read legacy metadata: %w", err)
|
||||
}
|
||||
metadata := parseLegacyPluginMetadata(string(data))
|
||||
repo := metadata["repo"]
|
||||
path := metadata["path"]
|
||||
repoDir := metadata["repodir"]
|
||||
if repo == "" || repoDir == "" {
|
||||
return LockedPlugin{}, "", fmt.Errorf("legacy metadata is incomplete")
|
||||
}
|
||||
if err := validatePluginRepo(repo); err != nil {
|
||||
return LockedPlugin{}, "", err
|
||||
}
|
||||
if !isSafePluginPathComponent(repoDir) {
|
||||
return LockedPlugin{}, "", fmt.Errorf("legacy repository directory is invalid")
|
||||
}
|
||||
if err := validatePluginRepoPath(path); err != nil {
|
||||
return LockedPlugin{}, "", err
|
||||
}
|
||||
return LockedPlugin{Repo: repo, Path: path}, filepath.Join(m.pluginsDir, ".repos", repoDir), nil
|
||||
}
|
||||
|
||||
repo, err := m.gitClient.OriginURL(pluginPath)
|
||||
if err != nil {
|
||||
return LockedPlugin{}, "", fmt.Errorf("plugin is not a managed git checkout")
|
||||
}
|
||||
if err := validatePluginRepo(repo); err != nil {
|
||||
return LockedPlugin{}, "", err
|
||||
}
|
||||
return LockedPlugin{Repo: repo}, pluginPath, nil
|
||||
}
|
||||
|
||||
func parseLegacyPluginMetadata(data string) map[string]string {
|
||||
metadata := make(map[string]string)
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if ok {
|
||||
metadata[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (m *Manager) recordInstalledPlugin(plugin Plugin, repoPath string) error {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commit, err := m.gitClient.CurrentRevision(repoPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read installed plugin revision: %w", err)
|
||||
}
|
||||
|
||||
lock.SetRepositoryCommit(plugin.Repo, commit)
|
||||
lock.Plugins[plugin.ID] = LockedPlugin{
|
||||
Repo: plugin.Repo,
|
||||
Path: plugin.Path,
|
||||
Commit: commit,
|
||||
}
|
||||
return m.lockStore().Write(lock)
|
||||
}
|
||||
|
||||
func (m *Manager) WriteCurrentLockfile(outputPath string) ([]string, error) {
|
||||
lock, warnings, err := m.SnapshotLockfile()
|
||||
if err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
if err := m.lockStore().Write(lock); err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
if outputPath != "" && outputPath != m.GetLockfilePath() {
|
||||
if err := NewLockStore(m.fs, outputPath).Write(lock); err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
}
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
func (m *Manager) RestoreFromLockfile(sourcePath string, prune bool) error {
|
||||
if sourcePath == "" {
|
||||
sourcePath = m.GetLockfilePath()
|
||||
}
|
||||
sourceStore := NewLockStore(m.fs, sourcePath)
|
||||
exists, err := sourceStore.Exists()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check plugin lockfile: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("plugin lockfile not found: %s", sourcePath)
|
||||
}
|
||||
target, err := sourceStore.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range target.IDs() {
|
||||
wanted := target.Plugins[id]
|
||||
installedPath, err := m.findInstalledPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if installedPath != "" {
|
||||
existing, managed := current.Plugins[id]
|
||||
if !managed {
|
||||
return fmt.Errorf("plugin %q is already installed but is not managed by the lockfile", id)
|
||||
}
|
||||
if existing.Repo != wanted.Repo || existing.Path != wanted.Path {
|
||||
if err := m.UninstallByIDOrName(id); err != nil {
|
||||
return err
|
||||
}
|
||||
installedPath = ""
|
||||
}
|
||||
}
|
||||
|
||||
plugin := Plugin{ID: id, Name: id, Repo: wanted.Repo, Path: wanted.Path}
|
||||
newlyInstalled := installedPath == ""
|
||||
if installedPath == "" {
|
||||
if err := m.Install(plugin); err != nil {
|
||||
return fmt.Errorf("failed to restore plugin %q: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
repoPath := m.repositoryPath(id, wanted)
|
||||
previousCommit, err := m.gitClient.CurrentRevision(repoPath)
|
||||
if err != nil {
|
||||
if newlyInstalled {
|
||||
m.UninstallByIDOrName(id) //nolint:errcheck
|
||||
}
|
||||
return fmt.Errorf("failed to read current revision for plugin %q: %w", id, err)
|
||||
}
|
||||
if err := m.gitClient.CheckoutRevision(repoPath, wanted.Commit); err != nil {
|
||||
if newlyInstalled {
|
||||
if rollbackErr := m.UninstallByIDOrName(id); rollbackErr != nil {
|
||||
return fmt.Errorf("failed to restore plugin %q at %s: %w (also failed to remove the partial install: %v)", id, wanted.Commit, err, rollbackErr)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed to restore plugin %q at %s: %w", id, wanted.Commit, err)
|
||||
}
|
||||
rollback := func(restoreErr error) error {
|
||||
if newlyInstalled {
|
||||
if rollbackErr := m.UninstallByIDOrName(id); rollbackErr != nil {
|
||||
return fmt.Errorf("%w (also failed to remove the partial install: %v)", restoreErr, rollbackErr)
|
||||
}
|
||||
return restoreErr
|
||||
}
|
||||
if rollbackErr := m.gitClient.CheckoutRevision(repoPath, previousCommit); rollbackErr != nil {
|
||||
return fmt.Errorf("%w (also failed to restore revision %s: %v)", restoreErr, previousCommit, rollbackErr)
|
||||
}
|
||||
return restoreErr
|
||||
}
|
||||
pluginPath := installedPath
|
||||
if pluginPath == "" {
|
||||
pluginPath = filepath.Join(m.pluginsDir, id)
|
||||
}
|
||||
if actualID := m.getPluginID(pluginPath); actualID != id {
|
||||
return rollback(fmt.Errorf("restored plugin %q has manifest id %q", id, actualID))
|
||||
}
|
||||
if err := m.recordInstalledPlugin(plugin, repoPath); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
current, err = m.lockStore().Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if prune {
|
||||
for _, id := range current.IDs() {
|
||||
if _, wanted := target.Plugins[id]; wanted {
|
||||
continue
|
||||
}
|
||||
if err := m.UninstallByIDOrName(id); err != nil {
|
||||
return fmt.Errorf("failed to prune plugin %q: %w", id, err)
|
||||
}
|
||||
}
|
||||
return m.lockStore().Write(target)
|
||||
}
|
||||
|
||||
merged, err := m.lockStore().Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for id, plugin := range target.Plugins {
|
||||
merged.SetRepositoryCommit(plugin.Repo, plugin.Commit)
|
||||
merged.Plugins[id] = plugin
|
||||
}
|
||||
return m.lockStore().Write(merged)
|
||||
}
|
||||
|
||||
func (m *Manager) repositoryPath(pluginID string, plugin LockedPlugin) string {
|
||||
if plugin.Path != "" {
|
||||
return filepath.Join(m.pluginsDir, ".repos", m.getRepoName(plugin.Repo))
|
||||
}
|
||||
if installedPath, err := m.findInDir(m.pluginsDir, pluginID); err == nil && installedPath != "" {
|
||||
return installedPath
|
||||
}
|
||||
return filepath.Join(m.pluginsDir, pluginID)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -16,6 +17,7 @@ func setupTestManager(t *testing.T) (*Manager, afero.Fs, string) {
|
||||
manager := &Manager{
|
||||
fs: fs,
|
||||
pluginsDir: pluginsDir,
|
||||
lockPath: "/config/plugins.lock.json",
|
||||
gitClient: &mockGitClient{},
|
||||
}
|
||||
return manager, fs, pluginsDir
|
||||
@@ -245,3 +247,244 @@ func TestManagerGetPluginsDir(t *testing.T) {
|
||||
manager, _, pluginsDir := setupTestManager(t)
|
||||
assert.Equal(t, pluginsDir, manager.GetPluginsDir())
|
||||
}
|
||||
|
||||
func TestInstallUpdatesLockfile(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"}
|
||||
manager.gitClient = &mockGitClient{
|
||||
cloneFunc: func(path, _ string) error {
|
||||
require.NoError(t, fs.MkdirAll(path, 0o755))
|
||||
return afero.WriteFile(fs, filepath.Join(path, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644)
|
||||
},
|
||||
revisionFunc: func(string) (string, error) { return testCommit, nil },
|
||||
}
|
||||
|
||||
require.NoError(t, manager.Install(plugin))
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, LockedPlugin{Repo: plugin.Repo, Commit: testCommit}, lock.Plugins[plugin.ID])
|
||||
exists, err := afero.Exists(fs, filepath.Join(pluginsDir, plugin.ID))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
}
|
||||
|
||||
func TestUpdateRefreshesLockedCommit(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"}
|
||||
pluginPath := filepath.Join(pluginsDir, plugin.ID)
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644))
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
plugin.ID: {Repo: plugin.Repo, Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
newCommit := "1123456789abcdef0123456789abcdef01234567"
|
||||
manager.gitClient = &mockGitClient{
|
||||
pullFunc: func(string) error { return nil },
|
||||
revisionFunc: func(string) (string, error) { return newCommit, nil },
|
||||
}
|
||||
|
||||
require.NoError(t, manager.Update(plugin))
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newCommit, lock.Plugins[plugin.ID].Commit)
|
||||
}
|
||||
|
||||
func TestUpdateResolvesRenamedPluginDirectory(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"}
|
||||
pluginPath := filepath.Join(pluginsDir, "renamed-dir")
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644))
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
plugin.ID: {Repo: plugin.Repo, Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
var pulledPath string
|
||||
manager.gitClient = &mockGitClient{
|
||||
pullFunc: func(path string) error {
|
||||
pulledPath = path
|
||||
return nil
|
||||
},
|
||||
cloneFunc: func(path, _ string) error {
|
||||
t.Fatalf("unexpected clone to %s", path)
|
||||
return nil
|
||||
},
|
||||
revisionFunc: func(string) (string, error) { return testCommit, nil },
|
||||
}
|
||||
|
||||
require.NoError(t, manager.Update(plugin))
|
||||
assert.Equal(t, pluginPath, pulledPath)
|
||||
}
|
||||
|
||||
func TestUninstallRemovesLockedPlugin(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"}
|
||||
pluginPath := filepath.Join(pluginsDir, plugin.ID)
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644))
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
plugin.ID: {Repo: plugin.Repo, Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
|
||||
require.NoError(t, manager.Uninstall(plugin))
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, lock.Plugins, plugin.ID)
|
||||
}
|
||||
|
||||
func TestRestoreFromLockfileInstallsExactCommit(t *testing.T) {
|
||||
manager, fs, _ := setupTestManager(t)
|
||||
wantedCommit := "2123456789abcdef0123456789abcdef01234567"
|
||||
incoming := PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
"restored": {Repo: "https://github.com/test/restored.git", Commit: wantedCommit},
|
||||
},
|
||||
}
|
||||
require.NoError(t, NewLockStore(fs, "/incoming.lock.json").Write(incoming))
|
||||
currentCommit := testCommit
|
||||
manager.gitClient = &mockGitClient{
|
||||
cloneFunc: func(path, _ string) error {
|
||||
require.NoError(t, fs.MkdirAll(path, 0o755))
|
||||
return afero.WriteFile(fs, filepath.Join(path, "plugin.json"), []byte(`{"id":"restored"}`), 0o644)
|
||||
},
|
||||
revisionFunc: func(string) (string, error) { return currentCommit, nil },
|
||||
checkoutFunc: func(_ string, revision string) error {
|
||||
currentCommit = revision
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, manager.RestoreFromLockfile("/incoming.lock.json", false))
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, wantedCommit, lock.Plugins["restored"].Commit)
|
||||
}
|
||||
|
||||
func TestSnapshotLockfileMigratesLegacyMonorepoMetadata(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
pluginPath := filepath.Join(pluginsDir, "legacy")
|
||||
repoPath := filepath.Join(pluginsDir, ".repos", "legacy-repo")
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, fs.MkdirAll(repoPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"legacy"}`), 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, pluginPath+".meta", []byte("repo=https://github.com/test/suite.git\npath=plugins/legacy\nrepodir=legacy-repo"), 0o644))
|
||||
manager.gitClient = &mockGitClient{
|
||||
revisionFunc: func(path string) (string, error) {
|
||||
assert.Equal(t, repoPath, path)
|
||||
return testCommit, nil
|
||||
},
|
||||
}
|
||||
|
||||
lock, warnings, err := manager.SnapshotLockfile()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Equal(t, LockedPlugin{
|
||||
Repo: "https://github.com/test/suite.git",
|
||||
Path: "plugins/legacy",
|
||||
Commit: testCommit,
|
||||
}, lock.Plugins["legacy"])
|
||||
}
|
||||
|
||||
func TestRecordInstalledPluginUpdatesSharedRepositoryCommit(t *testing.T) {
|
||||
manager, _, _ := setupTestManager(t)
|
||||
repo := "https://github.com/test/suite.git"
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
"one": {Repo: repo, Path: "plugins/one", Commit: testCommit},
|
||||
"two": {Repo: repo, Path: "plugins/two", Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
newCommit := "3123456789abcdef0123456789abcdef01234567"
|
||||
manager.gitClient = &mockGitClient{
|
||||
revisionFunc: func(string) (string, error) { return newCommit, nil },
|
||||
}
|
||||
|
||||
require.NoError(t, manager.recordInstalledPlugin(Plugin{ID: "one", Repo: repo, Path: "plugins/one"}, "/repo"))
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newCommit, lock.Plugins["one"].Commit)
|
||||
assert.Equal(t, newCommit, lock.Plugins["two"].Commit)
|
||||
}
|
||||
|
||||
func TestRestoreFromLockfilePrunesManagedPlugins(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
pluginPath := filepath.Join(pluginsDir, "old")
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"old","name":"Old"}`), 0o644))
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
"old": {Repo: "https://github.com/test/old.git", Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
require.NoError(t, NewLockStore(fs, "/empty.lock.json").Write(NewPluginLockfile()))
|
||||
|
||||
require.NoError(t, manager.RestoreFromLockfile("/empty.lock.json", true))
|
||||
exists, err := afero.Exists(fs, pluginPath)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists)
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, lock.Plugins)
|
||||
}
|
||||
|
||||
func TestRestoreMissingLockfileDoesNotPrune(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
pluginPath := filepath.Join(pluginsDir, "kept")
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"kept"}`), 0o644))
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
"kept": {Repo: "https://github.com/test/kept.git", Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
|
||||
err := manager.RestoreFromLockfile("/missing.lock.json", true)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
exists, statErr := afero.Exists(fs, pluginPath)
|
||||
require.NoError(t, statErr)
|
||||
assert.True(t, exists)
|
||||
}
|
||||
|
||||
func TestRestoreFailedRevisionRemovesPartialInstall(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
incoming := PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
"broken": {Repo: "https://github.com/test/broken.git", Commit: testCommit},
|
||||
},
|
||||
}
|
||||
require.NoError(t, NewLockStore(fs, "/incoming.lock.json").Write(incoming))
|
||||
manager.gitClient = &mockGitClient{
|
||||
cloneFunc: func(path, _ string) error {
|
||||
require.NoError(t, fs.MkdirAll(path, 0o755))
|
||||
return afero.WriteFile(fs, filepath.Join(path, "plugin.json"), []byte(`{"id":"broken"}`), 0o644)
|
||||
},
|
||||
revisionFunc: func(string) (string, error) { return testCommit, nil },
|
||||
checkoutFunc: func(string, string) error {
|
||||
return errors.New("revision unavailable")
|
||||
},
|
||||
}
|
||||
|
||||
err := manager.RestoreFromLockfile("/incoming.lock.json", false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "revision unavailable")
|
||||
exists, statErr := afero.Exists(fs, filepath.Join(pluginsDir, "broken"))
|
||||
require.NoError(t, statErr)
|
||||
assert.False(t, exists)
|
||||
lock, loadErr := manager.lockStore().Load()
|
||||
require.NoError(t, loadErr)
|
||||
assert.NotContains(t, lock.Plugins, "broken")
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/go-git/go-git/v6"
|
||||
"github.com/go-git/go-git/v6/plumbing"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
@@ -35,6 +36,8 @@ type GitClient interface {
|
||||
Pull(path string) error
|
||||
OriginURL(path string) (string, error)
|
||||
HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err error)
|
||||
CurrentRevision(path string) (string, error)
|
||||
CheckoutRevision(path string, revision string) error
|
||||
}
|
||||
|
||||
type realGitClient struct{}
|
||||
@@ -135,6 +138,47 @@ func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
return localHash != remoteHead, localHash, remoteHead, nil
|
||||
}
|
||||
|
||||
func (g *realGitClient) CurrentRevision(path string) (string, error) {
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return head.Hash().String(), nil
|
||||
}
|
||||
|
||||
func (g *realGitClient) CheckoutRevision(path string, revision string) error {
|
||||
if !gitCommitPattern.MatchString(revision) {
|
||||
return fmt.Errorf("invalid git revision %q", revision)
|
||||
}
|
||||
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash := plumbing.NewHash(revision)
|
||||
if _, objectErr := repo.CommitObject(hash); objectErr != nil {
|
||||
if _, remoteErr := repo.Remote("origin"); remoteErr != nil {
|
||||
return fmt.Errorf("revision %s is not available in repository: %w", revision, objectErr)
|
||||
}
|
||||
fetchErr := repo.Fetch(&git.FetchOptions{})
|
||||
if fetchErr != nil && fetchErr != git.NoErrAlreadyUpToDate {
|
||||
return fmt.Errorf("failed to fetch locked revision: %w", fetchErr)
|
||||
}
|
||||
}
|
||||
if _, err := repo.CommitObject(hash); err != nil {
|
||||
return fmt.Errorf("revision %s is not available in repository: %w", revision, err)
|
||||
}
|
||||
worktree, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return worktree.Reset(&git.ResetOptions{Commit: hash, Mode: git.HardReset})
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
fs afero.Fs
|
||||
cacheDir string
|
||||
|
||||
@@ -3,10 +3,14 @@ package plugins
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/go-git/go-git/v6"
|
||||
"github.com/go-git/go-git/v6/plumbing/object"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -19,6 +23,8 @@ type mockGitClient struct {
|
||||
pullFunc func(path string) error
|
||||
originFunc func(path string) (string, error)
|
||||
hasUpdatesFunc func(path string) (bool, string, string, error)
|
||||
revisionFunc func(path string) (string, error)
|
||||
checkoutFunc func(path string, revision string) error
|
||||
}
|
||||
|
||||
func (m *mockGitClient) PlainClone(path string, url string) error {
|
||||
@@ -39,7 +45,7 @@ func (m *mockGitClient) OriginURL(path string) (string, error) {
|
||||
if m.originFunc != nil {
|
||||
return m.originFunc(path)
|
||||
}
|
||||
return "", errors.New("not a repository")
|
||||
return "https://github.com/test/plugin.git", nil
|
||||
}
|
||||
|
||||
func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
@@ -49,6 +55,20 @@ func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
return false, "", "", nil
|
||||
}
|
||||
|
||||
func (m *mockGitClient) CurrentRevision(path string) (string, error) {
|
||||
if m.revisionFunc != nil {
|
||||
return m.revisionFunc(path)
|
||||
}
|
||||
return "0123456789abcdef0123456789abcdef01234567", nil
|
||||
}
|
||||
|
||||
func (m *mockGitClient) CheckoutRevision(path string, revision string) error {
|
||||
if m.checkoutFunc != nil {
|
||||
return m.checkoutFunc(path, revision)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewRegistry(t *testing.T) {
|
||||
registry, err := NewRegistry()
|
||||
assert.NoError(t, err)
|
||||
@@ -63,6 +83,41 @@ func TestGetCacheDir(t *testing.T) {
|
||||
assert.Contains(t, cacheDir, "/tmp/dankdots-plugin-registry")
|
||||
}
|
||||
|
||||
func TestRealGitClientRevisionCheckout(t *testing.T) {
|
||||
repoPath := t.TempDir()
|
||||
repo, err := git.PlainInit(repoPath, false)
|
||||
require.NoError(t, err)
|
||||
worktree, err := repo.Worktree()
|
||||
require.NoError(t, err)
|
||||
filePath := filepath.Join(repoPath, "value.txt")
|
||||
signature := &object.Signature{Name: "DMS Test", Email: "[email protected]", When: time.Unix(1, 0)}
|
||||
|
||||
require.NoError(t, os.WriteFile(filePath, []byte("one"), 0o644))
|
||||
_, err = worktree.Add("value.txt")
|
||||
require.NoError(t, err)
|
||||
first, err := worktree.Commit("first", &git.CommitOptions{Author: signature})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, os.WriteFile(filePath, []byte("two"), 0o644))
|
||||
_, err = worktree.Add("value.txt")
|
||||
require.NoError(t, err)
|
||||
second, err := worktree.Commit("second", &git.CommitOptions{Author: signature})
|
||||
require.NoError(t, err)
|
||||
|
||||
client := &realGitClient{}
|
||||
revision, err := client.CurrentRevision(repoPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, second.String(), revision)
|
||||
require.NoError(t, client.CheckoutRevision(repoPath, first.String()))
|
||||
|
||||
revision, err = client.CurrentRevision(repoPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first.String(), revision)
|
||||
data, err := os.ReadFile(filePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "one", string(data))
|
||||
}
|
||||
|
||||
func setupTestRegistry(t *testing.T) (*Registry, afero.Fs, string) {
|
||||
fs := afero.NewMemMapFs()
|
||||
tmpDir := "/test-cache"
|
||||
|
||||
Reference in new issue
Block a user