Repository: cli/cli (Go)

Issue:
`GitKind` extensions are not treated as pinned correctly
### Describe the bug

```
➜ gh ext install --pin 81a4ce86e027f31d306883c25a71b5d05b007e2e andyfeller/gh-sonar
⣷Cloning into '/Users/williammartin/.local/share/gh/extensions/gh-sonar'...
⢿remote: Enumerating objects: 10, done.
remote: Counting objects: 100% (10/10), done.
remote: Compressing objects: 100% (8/8), done.
⡿remote: Total 10 (delta 1), reused 7 (delta 1), pack-reused 0 (from 0)
Receiving objects: 100% (10/10), 6.64 KiB | 6.64 MiB/s, done.
Resolving deltas: 100% (1/1), done.
✓ Installed extension andyfeller/gh-sonar
✓ Pinned extension at 81a4ce86e027f31d306883c25a71b5d05b007e2e
```

This looks like it is pinned correctly. However...running `gh ext list` doesn't show it in blue (indicating pinned), and...

```
➜  gh ext upgrade gh-sonar --dry-run
[sonar]: would have upgraded from 81a4ce86 to 9977d8e5
✓ Successfully checked extension upgrades
```

Whereas a pinned extension would say:

```
➜  gh ext upgrade gh-sarif --dry-run
[sarif]: pinned extensions can not be upgraded
✓ Successfully checked extension upgrades
```

Separately, upgrading doesn't work at all, but that's another issue (to be created)

### Acceptance Criteria

**Given** I installed a pinned, script-based GitHub CLI extension using `gh ext install --pin SHA`
**When** I execute `gh ext upgrade <EXT> --dry-run`
**Then** `gh` states `pinned extensions can not be upgraded`

---

**Given** I installed a pinned, script-based GitHub CLI extension using `gh ext install --pin SHA`
**When** I execute `gh ext list`
**Then** I see the extension sha is coloured cyan

In the following example, the version for `gh-eco` is colored cyan because it is a pinned extension:

<img width="520" alt="Screenshot of terminal displaying GitHub CLI extensions with gh-eco version being cyan" src="https://github.com/user-attachments/assets/e6064e6c-e086-4f74-8a60-8cdbf62720d4" />



Discussion:
Thanks for opening this issue, @williammartin! ❤ 

If I'm following along, then I assume the acceptance criteria for this bug report is:

**Given** I installed a pinned, script-based GitHub CLI extension using `gh ext install --pin SHA`
**When** I execute `gh ext upgrade <EXT> --dry-run`
**Then** `gh` states `pinned extensions can not be upgraded`

Separately, I think the message provided by `gh ext upgrade` isn't actionable enough and wonder how we incorporate an additional message to help the user:

```shell
➜  gh ext upgrade gh-sarif --dry-run
[sarif]: pinned extensions can not be upgraded
✓ Successfully checked extension upgrades

To upgrade pinned extensions, run `gh ext install <ext> --force`.
```
This occurs because checking whether a `GitKind` extension is pinned tries to check the existence of a file in the git repo, but the path concatenation is incorrect, because it concatenates the executable path (rather than the dir containing the executable).

https://github.com/cli/cli/blob/41e99b9eabe47bc8262d5ebfc65626fb29a7ed87/pkg/cmd/extension/extension.go#L166

This was introduced in https://github.com/cli/cli/pull/7493 and has been broken since. The previous commit correctly represents the pinned ext in `ext list`.
I would also expect:

**Given** I installed a pinned, script-based GitHub CLI extension using `gh ext install --pin SHA`
**When** I execute `gh ext list`
**Then** I see the extension sha is coloured blue (or whatever my terminal colouring is set to for that)
~~Hello, I want to start contributing to open-source projects. This seems to be a good point to begin with. Where can I find the guide to contribute to the project? ~~

I already saw it in the readme



Patch:
diff --git a/pkg/cmd/extension/extension.go b/pkg/cmd/extension/extension.go
index dc7ffa8c3a3..f30bf63c15c 100644
--- a/pkg/cmd/extension/extension.go
+++ b/pkg/cmd/extension/extension.go
@@ -163,7 +163,8 @@ func (e *Extension) IsPinned() bool {
 			isPinned = manifest.IsPinned
 		}
 	case GitKind:
-		pinPath := filepath.Join(e.Path(), fmt.Sprintf(".pin-%s", e.CurrentVersion()))
+		extDir := filepath.Dir(e.path)
+		pinPath := filepath.Join(extDir, fmt.Sprintf(".pin-%s", e.CurrentVersion()))
 		if _, err := os.Stat(pinPath); err == nil {
 			isPinned = true
 		} else {


Test patch:
diff --git a/pkg/cmd/extension/extension_test.go b/pkg/cmd/extension/extension_test.go
index 6f0afc3a6cc..6928c6ed93d 100644
--- a/pkg/cmd/extension/extension_test.go
+++ b/pkg/cmd/extension/extension_test.go
@@ -102,3 +102,84 @@ func TestOwnerCached(t *testing.T) {
 
 	assert.Equal(t, "cli", e.Owner())
 }
+
+func TestIsPinnedBinaryExtensionUnpinned(t *testing.T) {
+	tempDir := t.TempDir()
+	extName := "gh-bin-ext"
+	extDir := filepath.Join(tempDir, "extensions", extName)
+	extPath := filepath.Join(extDir, extName)
+	bm := binManifest{
+		Name: "gh-bin-ext",
+	}
+	assert.NoError(t, stubBinaryExtension(extDir, bm))
+	e := &Extension{
+		kind: BinaryKind,
+		path: extPath,
+	}
+
+	assert.False(t, e.IsPinned())
+}
+
+func TestIsPinnedBinaryExtensionPinned(t *testing.T) {
+	tempDir := t.TempDir()
+	extName := "gh-bin-ext"
+	extDir := filepath.Join(tempDir, "extensions", extName)
+	extPath := filepath.Join(extDir, extName)
+	bm := binManifest{
+		Name:     "gh-bin-ext",
+		IsPinned: true,
+	}
+	assert.NoError(t, stubBinaryExtension(extDir, bm))
+	e := &Extension{
+		kind: BinaryKind,
+		path: extPath,
+	}
+
+	assert.True(t, e.IsPinned())
+}
+
+func TestIsPinnedGitExtensionUnpinned(t *testing.T) {
+	tempDir := t.TempDir()
+	extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local")
+	assert.NoError(t, stubExtension(extPath))
+
+	gc := &mockGitClient{}
+	gc.On("CommandOutput", []string{"rev-parse", "HEAD"}).Return("abcd1234", nil)
+	e := &Extension{
+		kind:      GitKind,
+		gitClient: gc,
+		path:      extPath,
+	}
+
+	assert.False(t, e.IsPinned())
+	gc.AssertExpectations(t)
+}
+
+func TestIsPinnedGitExtensionPinned(t *testing.T) {
+	tempDir := t.TempDir()
+	extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local")
+	assert.NoError(t, stubPinnedExtension(extPath, "abcd1234"))
+
+	gc := &mockGitClient{}
+	gc.On("CommandOutput", []string{"rev-parse", "HEAD"}).Return("abcd1234", nil)
+	e := &Extension{
+		kind:      GitKind,
+		gitClient: gc,
+		path:      extPath,
+	}
+
+	assert.True(t, e.IsPinned())
+	gc.AssertExpectations(t)
+}
+
+func TestIsPinnedLocalExtension(t *testing.T) {
+	tempDir := t.TempDir()
+	extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local")
+	assert.NoError(t, stubLocalExtension(tempDir, extPath))
+	e := &Extension{
+		kind: LocalKind,
+		path: extPath,
+	}
+
+	assert.False(t, e.IsPinned())
+}