diff --git a/pkg/cmd/extensions/command.go b/pkg/cmd/extensions/command.go index c7fc031da..7e6644900 100644 --- a/pkg/cmd/extensions/command.go +++ b/pkg/cmd/extensions/command.go @@ -43,7 +43,7 @@ func NewCmdExtensions(f *cmdutil.Factory) *cobra.Command { if len(cmds) == 0 { return errors.New("no extensions installed") } - // cs := io.ColorScheme() + cs := io.ColorScheme() t := utils.NewTablePrinter(io) for _, c := range cmds { var repo string @@ -55,8 +55,11 @@ func NewCmdExtensions(f *cmdutil.Factory) *cobra.Command { t.AddField(fmt.Sprintf("gh %s", c.Name()), nil, nil) t.AddField(repo, nil, nil) - // TODO: add notice about available update - //t.AddField("Update available", nil, cs.Green) + var updateAvailable string + if c.UpdateAvailable() { + updateAvailable = "Update available" + } + t.AddField(updateAvailable, nil, cs.Green) t.EndRow() } return t.Render() diff --git a/pkg/cmd/extensions/command_test.go b/pkg/cmd/extensions/command_test.go index f67664eaa..ac173f3c8 100644 --- a/pkg/cmd/extensions/command_test.go +++ b/pkg/cmd/extensions/command_test.go @@ -146,6 +146,21 @@ func TestNewCmdExtensions(t *testing.T) { isTTY: false, wantStdout: "", }, + { + name: "list extensions", + args: []string{"list"}, + managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { + em.ListFunc = func() []extensions.Extension { + ex1 := &Extension{path: "cli/gh-test", url: "https://github.com/cli/gh-test", updateAvailable: false} + ex2 := &Extension{path: "cli/gh-test2", url: "https://github.com/cli/gh-test2", updateAvailable: true} + return []extensions.Extension{ex1, ex2} + } + return func(t *testing.T) { + assert.Equal(t, 1, len(em.ListCalls())) + } + }, + wantStdout: "gh test\tcli/gh-test\t\ngh test2\tcli/gh-test2\tUpdate available\n", + }, } for _, tt := range tests { diff --git a/pkg/cmd/extensions/extension.go b/pkg/cmd/extensions/extension.go index 8fa07e7cf..c3a391f6f 100644 --- a/pkg/cmd/extensions/extension.go +++ b/pkg/cmd/extensions/extension.go @@ -8,8 +8,9 @@ import ( ) type Extension struct { - path string - url string + path string + url string + updateAvailable bool } func (e *Extension) Name() string { @@ -40,3 +41,7 @@ func (e *Extension) IsLocal() bool { } return false } + +func (e *Extension) UpdateAvailable() bool { + return e.updateAvailable +} diff --git a/pkg/cmd/extensions/manager.go b/pkg/cmd/extensions/manager.go index ff6452657..bbca3395e 100644 --- a/pkg/cmd/extensions/manager.go +++ b/pkg/cmd/extensions/manager.go @@ -87,34 +87,63 @@ func (m *Manager) list(includeMetadata bool) []extensions.Extension { if err != nil { return nil } - - var gitExe string - if includeMetadata { - gitExe, _ = m.lookPath("git") - } - var results []extensions.Extension for _, f := range entries { if !strings.HasPrefix(f.Name(), "gh-") || !(f.IsDir() || f.Mode()&os.ModeSymlink != 0) { continue } - var remoteURL string - if gitExe != "" { - stdout := bytes.Buffer{} - cmd := m.newCommand(gitExe, "--git-dir="+filepath.Join(dir, f.Name(), ".git"), "config", "remote.origin.url") - cmd.Stdout = &stdout - if err := cmd.Run(); err == nil { - remoteURL = strings.TrimSpace(stdout.String()) - } + var remoteUrl string + var updateAvailable bool + if includeMetadata { + remoteUrl = m.getRemoteUrl(f.Name()) + updateAvailable = m.checkUpdateAvailable(f.Name()) } results = append(results, &Extension{ - path: filepath.Join(dir, f.Name(), f.Name()), - url: remoteURL, + path: filepath.Join(dir, f.Name(), f.Name()), + url: remoteUrl, + updateAvailable: updateAvailable, }) } return results } +func (m *Manager) getRemoteUrl(extension string) string { + gitExe, err := m.lookPath("git") + if err != nil { + return "" + } + dir := m.installDir() + gitDir := "--git-dir=" + filepath.Join(dir, extension, ".git") + cmd := m.newCommand(gitExe, gitDir, "config", "remote.origin.url") + url, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(url)) +} + +func (m *Manager) checkUpdateAvailable(extension string) bool { + gitExe, err := m.lookPath("git") + if err != nil { + return false + } + dir := m.installDir() + gitDir := "--git-dir=" + filepath.Join(dir, extension, ".git") + cmd := m.newCommand(gitExe, gitDir, "ls-remote", "origin", "HEAD") + lsRemote, err := cmd.Output() + if err != nil { + return false + } + remoteSha := bytes.SplitN(lsRemote, []byte("\t"), 2)[0] + cmd = m.newCommand(gitExe, gitDir, "rev-parse", "HEAD") + localSha, err := cmd.Output() + if err != nil { + return false + } + localSha = bytes.TrimSpace(localSha) + return !bytes.Equal(remoteSha, localSha) +} + func (m *Manager) InstallLocal(dir string) error { name := filepath.Base(dir) targetDir := filepath.Join(m.installDir(), name) diff --git a/pkg/extensions/extension.go b/pkg/extensions/extension.go index da915463a..4007e7d28 100644 --- a/pkg/extensions/extension.go +++ b/pkg/extensions/extension.go @@ -10,6 +10,7 @@ type Extension interface { Path() string URL() string IsLocal() bool + UpdateAvailable() bool } //go:generate moq -out manager_mock.go . ExtensionManager diff --git a/pkg/extensions/extension_mock.go b/pkg/extensions/extension_mock.go index 8fe35c1f6..17e2a3f6b 100644 --- a/pkg/extensions/extension_mock.go +++ b/pkg/extensions/extension_mock.go @@ -29,6 +29,9 @@ var _ Extension = &ExtensionMock{} // URLFunc: func() string { // panic("mock out the URL method") // }, +// UpdateAvailableFunc: func() bool { +// panic("mock out the UpdateAvailable method") +// }, // } // // // use mockedExtension in code that requires Extension @@ -48,6 +51,9 @@ type ExtensionMock struct { // URLFunc mocks the URL method. URLFunc func() string + // UpdateAvailableFunc mocks the UpdateAvailable method. + UpdateAvailableFunc func() bool + // calls tracks calls to the methods. calls struct { // IsLocal holds details about calls to the IsLocal method. @@ -62,11 +68,15 @@ type ExtensionMock struct { // URL holds details about calls to the URL method. URL []struct { } + // UpdateAvailable holds details about calls to the UpdateAvailable method. + UpdateAvailable []struct { + } } - lockIsLocal sync.RWMutex - lockName sync.RWMutex - lockPath sync.RWMutex - lockURL sync.RWMutex + lockIsLocal sync.RWMutex + lockName sync.RWMutex + lockPath sync.RWMutex + lockURL sync.RWMutex + lockUpdateAvailable sync.RWMutex } // IsLocal calls IsLocalFunc. @@ -172,3 +182,29 @@ func (mock *ExtensionMock) URLCalls() []struct { mock.lockURL.RUnlock() return calls } + +// UpdateAvailable calls UpdateAvailableFunc. +func (mock *ExtensionMock) UpdateAvailable() bool { + if mock.UpdateAvailableFunc == nil { + panic("ExtensionMock.UpdateAvailableFunc: method is nil but Extension.UpdateAvailable was just called") + } + callInfo := struct { + }{} + mock.lockUpdateAvailable.Lock() + mock.calls.UpdateAvailable = append(mock.calls.UpdateAvailable, callInfo) + mock.lockUpdateAvailable.Unlock() + return mock.UpdateAvailableFunc() +} + +// UpdateAvailableCalls gets all the calls that were made to UpdateAvailable. +// Check the length with: +// len(mockedExtension.UpdateAvailableCalls()) +func (mock *ExtensionMock) UpdateAvailableCalls() []struct { +} { + var calls []struct { + } + mock.lockUpdateAvailable.RLock() + calls = mock.calls.UpdateAvailable + mock.lockUpdateAvailable.RUnlock() + return calls +}