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" />


Pull request:
[gh ext] Fix `GitKind` extension directory path
Fixes #10228.

### Tests

```shell
$ bin/gh ext install --pin 81a4ce86e027f31d306883c25a71b5d05b007e2e andyfeller/gh-sonar
⡿Cloning into '/home/azeem/.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


$ bin/gh ext list
NAME            REPO                 VERSION 
gh annotations  swfz/gh-annotations  v1.0.0
gh copilot      github/gh-copilot    v1.1.0
gh sonar        andyfeller/gh-sonar  81a4ce86


$ bin/gh ext upgrade gh-sonar --dry-run 
[sonar]: pinned extensions can not be upgraded
✓ Successfully checked extension upgrades

$ bin/gh ext upgrade gh-sonar
[sonar]: pinned extensions can not be upgraded
✓ Successfully checked extension upgrades
```

Screenshot:

![Screenshot from 2025-03-15 17-58-21](https://github.com/user-attachments/assets/9003ecac-13b6-4a81-990c-14de55b26be8)

Patch:
diff --git a/pkg/cmd/extension/extension.go b/pkg/cmd/extension/extension.go
index dc7ffa8c3..f30bf63c1 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 6f0afc3a6..6928c6ed9 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())
+}