Skip to content

Commit 968b093

Browse files
authored
Merge pull request cli#4396 from cli/speedy-extension-list
Use concurrency to check for extension updates
2 parents 7abf682 + ed34279 commit 968b093

File tree

3 files changed

+151
-86
lines changed

3 files changed

+151
-86
lines changed

pkg/cmd/extension/command_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,8 @@ func TestNewCmdExtension(t *testing.T) {
214214
args: []string{"list"},
215215
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
216216
em.ListFunc = func(bool) []extensions.Extension {
217-
ex1 := &Extension{path: "cli/gh-test", url: "https://github.com/cli/gh-test", updateAvailable: false}
218-
ex2 := &Extension{path: "cli/gh-test2", url: "https://github.com/cli/gh-test2", updateAvailable: true}
217+
ex1 := &Extension{path: "cli/gh-test", url: "https://github.com/cli/gh-test", currentVersion: "1", latestVersion: "1"}
218+
ex2 := &Extension{path: "cli/gh-test2", url: "https://github.com/cli/gh-test2", currentVersion: "1", latestVersion: "2"}
219219
return []extensions.Extension{ex1, ex2}
220220
}
221221
return func(t *testing.T) {

pkg/cmd/extension/extension.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,20 @@ import (
77

88
const manifestName = "manifest.yml"
99

10+
type ExtensionKind int
11+
12+
const (
13+
GitKind ExtensionKind = iota
14+
BinaryKind
15+
)
16+
1017
type Extension struct {
11-
path string
12-
url string
13-
isLocal bool
14-
updateAvailable bool
18+
path string
19+
url string
20+
isLocal bool
21+
currentVersion string
22+
latestVersion string
23+
kind ExtensionKind
1524
}
1625

1726
func (e *Extension) Name() string {
@@ -31,5 +40,11 @@ func (e *Extension) IsLocal() bool {
3140
}
3241

3342
func (e *Extension) UpdateAvailable() bool {
34-
return e.updateAvailable
43+
if e.isLocal ||
44+
e.currentVersion == "" ||
45+
e.latestVersion == "" ||
46+
e.currentVersion == e.latestVersion {
47+
return false
48+
}
49+
return true
3550
}

pkg/cmd/extension/manager.go

Lines changed: 129 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"path/filepath"
1515
"runtime"
1616
"strings"
17+
"sync"
1718

1819
"github.com/MakeNowJust/heredoc"
1920
"github.com/cli/cli/v2/api"
@@ -103,146 +104,195 @@ func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Wri
103104

104105
func (m *Manager) List(includeMetadata bool) []extensions.Extension {
105106
exts, _ := m.list(includeMetadata)
106-
return exts
107+
r := make([]extensions.Extension, len(exts))
108+
for i, v := range exts {
109+
val := v
110+
r[i] = &val
111+
}
112+
return r
107113
}
108114

109-
func (m *Manager) list(includeMetadata bool) ([]extensions.Extension, error) {
115+
func (m *Manager) list(includeMetadata bool) ([]Extension, error) {
110116
dir := m.installDir()
111117
entries, err := ioutil.ReadDir(dir)
112118
if err != nil {
113119
return nil, err
114120
}
115121

116-
var results []extensions.Extension
122+
var results []Extension
117123
for _, f := range entries {
118124
if !strings.HasPrefix(f.Name(), "gh-") {
119125
continue
120126
}
121-
ext, err := m.parseExtensionDir(f, includeMetadata)
122-
if err != nil {
123-
return nil, err
127+
var ext Extension
128+
var err error
129+
if f.IsDir() {
130+
ext, err = m.parseExtensionDir(f)
131+
if err != nil {
132+
return nil, err
133+
}
134+
results = append(results, ext)
135+
} else {
136+
ext, err = m.parseExtensionFile(f)
137+
if err != nil {
138+
return nil, err
139+
}
140+
results = append(results, ext)
124141
}
125-
results = append(results, ext)
142+
}
143+
144+
if includeMetadata {
145+
m.populateLatestVersions(results)
126146
}
127147

128148
return results, nil
129149
}
130150

131-
func (m *Manager) parseExtensionDir(fi fs.FileInfo, includeMetadata bool) (*Extension, error) {
151+
func (m *Manager) parseExtensionFile(fi fs.FileInfo) (Extension, error) {
152+
ext := Extension{isLocal: true}
153+
id := m.installDir()
154+
exePath := filepath.Join(id, fi.Name(), fi.Name())
155+
if !isSymlink(fi.Mode()) {
156+
// if this is a regular file, its contents is the local directory of the extension
157+
p, err := readPathFromFile(filepath.Join(id, fi.Name()))
158+
if err != nil {
159+
return ext, err
160+
}
161+
exePath = filepath.Join(p, fi.Name())
162+
}
163+
ext.path = exePath
164+
return ext, nil
165+
}
166+
167+
func (m *Manager) parseExtensionDir(fi fs.FileInfo) (Extension, error) {
132168
id := m.installDir()
133169
if _, err := os.Stat(filepath.Join(id, fi.Name(), manifestName)); err == nil {
134-
return m.parseBinaryExtensionDir(fi, includeMetadata)
170+
return m.parseBinaryExtensionDir(fi)
135171
}
136172

137-
return m.parseGitExtensionDir(fi, includeMetadata)
173+
return m.parseGitExtensionDir(fi)
138174
}
139175

140-
func (m *Manager) parseBinaryExtensionDir(fi fs.FileInfo, includeMetadata bool) (*Extension, error) {
176+
func (m *Manager) parseBinaryExtensionDir(fi fs.FileInfo) (Extension, error) {
141177
id := m.installDir()
142178
exePath := filepath.Join(id, fi.Name(), fi.Name())
179+
ext := Extension{path: exePath, kind: BinaryKind}
143180
manifestPath := filepath.Join(id, fi.Name(), manifestName)
144181
manifest, err := os.ReadFile(manifestPath)
145182
if err != nil {
146-
return nil, fmt.Errorf("could not open %s for reading: %w", manifestPath, err)
183+
return ext, fmt.Errorf("could not open %s for reading: %w", manifestPath, err)
147184
}
148-
149185
var bm binManifest
150186
err = yaml.Unmarshal(manifest, &bm)
151187
if err != nil {
152-
return nil, fmt.Errorf("could not parse %s: %w", manifestPath, err)
188+
return ext, fmt.Errorf("could not parse %s: %w", manifestPath, err)
153189
}
154-
155190
repo := ghrepo.NewWithHost(bm.Owner, bm.Name, bm.Host)
156-
157-
var remoteURL string
158-
var updateAvailable bool
159-
160-
if includeMetadata {
161-
remoteURL = ghrepo.GenerateRepoURL(repo, "")
162-
var r *release
163-
r, err = fetchLatestRelease(m.client, repo)
164-
if err != nil {
165-
return nil, fmt.Errorf("failed to get release info for %s: %w", ghrepo.FullName(repo), err)
166-
}
167-
if bm.Tag != r.Tag {
168-
updateAvailable = true
169-
}
170-
}
171-
172-
return &Extension{
173-
path: exePath,
174-
url: remoteURL,
175-
updateAvailable: updateAvailable,
176-
}, nil
191+
remoteURL := ghrepo.GenerateRepoURL(repo, "")
192+
ext.url = remoteURL
193+
ext.currentVersion = bm.Tag
194+
return ext, nil
177195
}
178196

179-
func (m *Manager) parseGitExtensionDir(fi fs.FileInfo, includeMetadata bool) (*Extension, error) {
180-
// TODO untangle local from this since local might be binary or git
197+
func (m *Manager) parseGitExtensionDir(fi fs.FileInfo) (Extension, error) {
181198
id := m.installDir()
182-
var remoteUrl string
183-
updateAvailable := false
184-
isLocal := false
185199
exePath := filepath.Join(id, fi.Name(), fi.Name())
186-
if fi.IsDir() {
187-
if includeMetadata {
188-
remoteUrl = m.getRemoteUrl(fi.Name())
189-
updateAvailable = m.checkUpdateAvailable(fi.Name())
190-
}
191-
} else {
192-
isLocal = true
193-
if !isSymlink(fi.Mode()) {
194-
// if this is a regular file, its contents is the local directory of the extension
195-
p, err := readPathFromFile(filepath.Join(id, fi.Name()))
196-
if err != nil {
197-
return nil, err
198-
}
199-
exePath = filepath.Join(p, fi.Name())
200-
}
201-
}
202-
203-
return &Extension{
204-
path: exePath,
205-
url: remoteUrl,
206-
isLocal: isLocal,
207-
updateAvailable: updateAvailable,
200+
remoteUrl := m.getRemoteUrl(fi.Name())
201+
currentVersion := m.getCurrentVersion(fi.Name())
202+
return Extension{
203+
path: exePath,
204+
url: remoteUrl,
205+
isLocal: false,
206+
currentVersion: currentVersion,
207+
kind: GitKind,
208208
}, nil
209209
}
210210

211-
func (m *Manager) getRemoteUrl(extension string) string {
211+
// getCurrentVersion determines the current version for non-local git extensions.
212+
func (m *Manager) getCurrentVersion(extension string) string {
212213
gitExe, err := m.lookPath("git")
213214
if err != nil {
214215
return ""
215216
}
216217
dir := m.installDir()
217218
gitDir := "--git-dir=" + filepath.Join(dir, extension, ".git")
218-
cmd := m.newCommand(gitExe, gitDir, "config", "remote.origin.url")
219-
url, err := cmd.Output()
219+
cmd := m.newCommand(gitExe, gitDir, "rev-parse", "HEAD")
220+
localSha, err := cmd.Output()
220221
if err != nil {
221222
return ""
222223
}
223-
return strings.TrimSpace(string(url))
224+
return string(bytes.TrimSpace(localSha))
224225
}
225226

226-
func (m *Manager) checkUpdateAvailable(extension string) bool {
227+
// getRemoteUrl determines the remote URL for non-local git extensions.
228+
func (m *Manager) getRemoteUrl(extension string) string {
227229
gitExe, err := m.lookPath("git")
228230
if err != nil {
229-
return false
231+
return ""
230232
}
231233
dir := m.installDir()
232234
gitDir := "--git-dir=" + filepath.Join(dir, extension, ".git")
233-
cmd := m.newCommand(gitExe, gitDir, "ls-remote", "origin", "HEAD")
234-
lsRemote, err := cmd.Output()
235+
cmd := m.newCommand(gitExe, gitDir, "config", "remote.origin.url")
236+
url, err := cmd.Output()
235237
if err != nil {
236-
return false
238+
return ""
237239
}
238-
remoteSha := bytes.SplitN(lsRemote, []byte("\t"), 2)[0]
239-
cmd = m.newCommand(gitExe, gitDir, "rev-parse", "HEAD")
240-
localSha, err := cmd.Output()
241-
if err != nil {
242-
return false
240+
return strings.TrimSpace(string(url))
241+
}
242+
243+
func (m *Manager) populateLatestVersions(exts []Extension) {
244+
size := len(exts)
245+
type result struct {
246+
index int
247+
version string
248+
}
249+
ch := make(chan result, size)
250+
var wg sync.WaitGroup
251+
wg.Add(size)
252+
for idx, ext := range exts {
253+
go func(i int, e Extension) {
254+
defer wg.Done()
255+
version, _ := m.getLatestVersion(e)
256+
ch <- result{index: i, version: version}
257+
}(idx, ext)
258+
}
259+
wg.Wait()
260+
close(ch)
261+
for r := range ch {
262+
ext := &exts[r.index]
263+
ext.latestVersion = r.version
264+
}
265+
}
266+
267+
func (m *Manager) getLatestVersion(ext Extension) (string, error) {
268+
if ext.isLocal {
269+
return "", fmt.Errorf("unable to get latest version for local extensions")
270+
}
271+
if ext.kind == GitKind {
272+
gitExe, err := m.lookPath("git")
273+
if err != nil {
274+
return "", err
275+
}
276+
extDir := filepath.Dir(ext.path)
277+
gitDir := "--git-dir=" + filepath.Join(extDir, ".git")
278+
cmd := m.newCommand(gitExe, gitDir, "ls-remote", "origin", "HEAD")
279+
lsRemote, err := cmd.Output()
280+
if err != nil {
281+
return "", err
282+
}
283+
remoteSha := bytes.SplitN(lsRemote, []byte("\t"), 2)[0]
284+
return string(remoteSha), nil
285+
} else {
286+
repo, err := ghrepo.FromFullName(ext.url)
287+
if err != nil {
288+
return "", err
289+
}
290+
r, err := fetchLatestRelease(m.client, repo)
291+
if err != nil {
292+
return "", err
293+
}
294+
return r.Tag, nil
243295
}
244-
localSha = bytes.TrimSpace(localSha)
245-
return !bytes.Equal(remoteSha, localSha)
246296
}
247297

248298
func (m *Manager) InstallLocal(dir string) error {

0 commit comments

Comments
 (0)