Skip to content

Commit 87d2158

Browse files
committed
feat: support SHA refs for plugin specs
1 parent e6defc8 commit 87d2158

10 files changed

Lines changed: 269 additions & 14 deletions

File tree

docs/getting-started/configuration.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,14 @@ Press ++prefix+shift+i++ to fetch and install all declared plugins.
6161
| Format | Example | Description |
6262
|---|---|---|
6363
| `user/repo` | `tmux-plugins/tmux-sensible` | GitHub shorthand |
64-
| `user/repo#branch` | `tmux-plugins/tmux-sensible#main` | Specific branch or tag |
64+
| `user/repo#ref` | `tmux-plugins/tmux-sensible#main` | Branch, tag, or commit SHA |
6565
| `https://github.com/user/repo.git` | `https://github.com/user/tmux-sensible.git` | Full HTTPS URL |
6666
| `[email protected]:user/plugin` | `[email protected]:tmux-plugins/tmux-sensible` | Full git SSH URL (GitHub) |
6767
| `[email protected]:user/plugin` | `[email protected]:user/tmux-plugin` | Non-GitHub git hosts |
6868
| `user/plugin alias=name` | `tmux-plugins/tmux-sensible alias=sensible` | Custom directory name |
6969

70+
Branches track upstream and fast-forward on update. Tags and commit SHAs are pinned (detached HEAD; no automatic updates).
71+
7072
For a list of compatible plugins, see the [tmux-plugins list](https://github.com/tmux-plugins/list).
7173

7274
## Next step

docs/usage/managing-plugins.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ See [Interactive TUI — Browse Screen](interactive-tui.md#browse-screen) for th
2323
!!! tip
2424
You can also install plugins directly from the [browse screen](interactive-tui.md#browse-screen) without manually editing your config.
2525

26+
## Pinning a plugin
27+
28+
Append `#<ref>` to pin a plugin to a tag or commit SHA. Pinned plugins are not fast-forwarded on update.
29+
30+
```bash
31+
set -g @plugin 'catppuccin/tmux#v2.1.3'
32+
set -g @plugin 'tmux-plugins/tmux-sensible#abc1234'
33+
```
34+
35+
To move to a new version, edit the ref in your config and run an update.
36+
2637
## Updating plugins
2738

2839
Press ++prefix+shift+u++ to update plugins. The TUI opens and you can select which plugins to update.

internal/git/cli/cloner.go

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cli
22

33
import (
44
"context"
5+
"fmt"
56
"os/exec"
67
"strconv"
78
"strings"
@@ -18,12 +19,17 @@ func NewCloner() *Cloner {
1819
}
1920

2021
func (c *Cloner) Clone(ctx context.Context, opts git.CloneOptions) error {
21-
args := []string{"clone", "--single-branch"}
22-
if opts.Depth > 0 {
23-
args = append(args, "--depth", strconv.Itoa(opts.Depth))
24-
}
25-
if opts.Branch != "" {
26-
args = append(args, "-b", opts.Branch)
22+
isSHA := opts.Branch != "" && looksLikeCommitSHA(opts.Branch)
23+
24+
args := []string{"clone"}
25+
if !isSHA {
26+
args = append(args, "--single-branch")
27+
if opts.Depth > 0 {
28+
args = append(args, "--depth", strconv.Itoa(opts.Depth))
29+
}
30+
if opts.Branch != "" {
31+
args = append(args, "-b", opts.Branch)
32+
}
2733
}
2834
args = append(args, opts.URL, opts.Dir)
2935

@@ -33,6 +39,15 @@ func (c *Cloner) Clone(ctx context.Context, opts git.CloneOptions) error {
3339
return err
3440
}
3541

42+
if isSHA {
43+
checkoutCmd := exec.CommandContext(ctx, "git", "checkout", opts.Branch)
44+
checkoutCmd.Dir = opts.Dir
45+
checkoutCmd.Env = append(checkoutCmd.Environ(), "GIT_TERMINAL_PROMPT=0")
46+
if out, err := checkoutCmd.CombinedOutput(); err != nil {
47+
return fmt.Errorf("git checkout %s: %w: %s", opts.Branch, err, strings.TrimSpace(string(out)))
48+
}
49+
}
50+
3651
// Submodules are best-effort: a plugin whose submodule references an
3752
// unreachable commit (see issue #14) should still install. The failure
3853
// is surfaced via OnWarning so callers can notify the user.

internal/git/cli/cloner_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,79 @@ func TestCloner_CloneWithBranch(t *testing.T) {
6868
}
6969
}
7070

71+
func TestCloner_CloneWithTag(t *testing.T) {
72+
if testing.Short() {
73+
t.Skip("skipping git CLI test in short mode")
74+
}
75+
76+
bare := initBareRepo(t)
77+
78+
// Tag the bare repo's HEAD via a temporary working copy.
79+
work := cloneLocal(t, bare)
80+
runGit(t, work, "tag", "v1.0.0")
81+
runGit(t, work, "push", "origin", "v1.0.0")
82+
83+
dst := filepath.Join(t.TempDir(), "cloned-tag")
84+
cloner := gitcli.NewCloner()
85+
err := cloner.Clone(context.Background(), git.CloneOptions{
86+
URL: bare,
87+
Dir: dst,
88+
Branch: "v1.0.0",
89+
})
90+
if err != nil {
91+
t.Fatalf("Clone with tag returned error: %v", err)
92+
}
93+
94+
// README from the initial (tagged) commit must exist.
95+
if _, err := os.Stat(filepath.Join(dst, "README")); err != nil {
96+
t.Fatalf("expected README in cloned repo: %v", err)
97+
}
98+
99+
// HEAD should be detached when cloning a tag.
100+
symCmd := exec.CommandContext(context.Background(), "git", "-C", dst, "symbolic-ref", "-q", "HEAD")
101+
if err := symCmd.Run(); err == nil {
102+
t.Fatal("expected detached HEAD after cloning a tag")
103+
}
104+
}
105+
106+
func TestCloner_CloneWithCommitSHA(t *testing.T) {
107+
if testing.Short() {
108+
t.Skip("skipping git CLI test in short mode")
109+
}
110+
111+
bare := initBareRepo(t)
112+
113+
// Capture the commit SHA of the bare repo's HEAD.
114+
work := cloneLocal(t, bare)
115+
revOut, err := exec.CommandContext(context.Background(),
116+
"git", "-C", work, "rev-parse", "HEAD").Output()
117+
if err != nil {
118+
t.Fatalf("rev-parse HEAD failed: %v", err)
119+
}
120+
sha := strings.TrimSpace(string(revOut))
121+
122+
dst := filepath.Join(t.TempDir(), "cloned-sha")
123+
cloner := gitcli.NewCloner()
124+
err = cloner.Clone(context.Background(), git.CloneOptions{
125+
URL: bare,
126+
Dir: dst,
127+
Branch: sha,
128+
})
129+
if err != nil {
130+
t.Fatalf("Clone with commit SHA returned error: %v", err)
131+
}
132+
133+
// HEAD must match the requested SHA exactly.
134+
headOut, err := exec.CommandContext(context.Background(),
135+
"git", "-C", dst, "rev-parse", "HEAD").Output()
136+
if err != nil {
137+
t.Fatalf("rev-parse HEAD on cloned repo failed: %v", err)
138+
}
139+
if got := strings.TrimSpace(string(headOut)); got != sha {
140+
t.Fatalf("HEAD = %s, want %s", got, sha)
141+
}
142+
}
143+
71144
func TestCloner_CloneInvalidURL(t *testing.T) {
72145
if testing.Short() {
73146
t.Skip("skipping git CLI test in short mode")

internal/git/cli/fetcher.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cli
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"os/exec"
78
"strings"
@@ -15,6 +16,16 @@ func NewFetcher() *Fetcher {
1516
}
1617

1718
func (c *Fetcher) IsOutdated(ctx context.Context, dir string) (bool, error) {
19+
// Detached HEAD (exit 1) means the plugin is pinned to a tag/SHA; never outdated.
20+
symCmd := exec.CommandContext(ctx, "git", "symbolic-ref", "-q", "HEAD")
21+
symCmd.Dir = dir
22+
if err := symCmd.Run(); err != nil {
23+
var exitErr *exec.ExitError
24+
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
25+
return false, nil
26+
}
27+
}
28+
1829
fetchCmd := exec.CommandContext(ctx, "git", "fetch")
1930
fetchCmd.Dir = dir
2031
fetchCmd.Env = append(fetchCmd.Environ(), "GIT_TERMINAL_PROMPT=0")

internal/git/cli/fetcher_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,29 @@ import (
88
gitcli "github.com/tmuxpack/tpack/internal/git/cli"
99
)
1010

11+
func TestFetcher_IsOutdatedDetachedHEAD(t *testing.T) {
12+
if testing.Short() {
13+
t.Skip("skipping git CLI test in short mode")
14+
}
15+
16+
bare := initBareRepo(t)
17+
clone := cloneLocal(t, bare)
18+
sha := revParse(t, clone, "HEAD")
19+
runGit(t, clone, "checkout", sha)
20+
21+
// Upstream moves forward; pinned plugin must still report not outdated.
22+
addCommitToBare(t, bare, "after-pin.txt")
23+
24+
fetcher := gitcli.NewFetcher()
25+
outdated, err := fetcher.IsOutdated(context.Background(), clone)
26+
if err != nil {
27+
t.Fatalf("IsOutdated returned error: %v", err)
28+
}
29+
if outdated {
30+
t.Fatal("expected pinned (detached HEAD) repo to report not outdated")
31+
}
32+
}
33+
1134
func TestFetcher_IsOutdatedUpToDate(t *testing.T) {
1235
if testing.Short() {
1336
t.Skip("skipping git CLI test in short mode")

internal/git/cli/helpers_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ var (
2121
_ git.Logger = (*gitcli.Logger)(nil)
2222
)
2323

24+
// Isolate git from the developer's user/system config.
25+
func TestMain(m *testing.M) {
26+
os.Setenv("GIT_CONFIG_GLOBAL", "/dev/null")
27+
os.Setenv("GIT_CONFIG_SYSTEM", "/dev/null")
28+
os.Exit(m.Run())
29+
}
30+
2431
// initBareRepo creates a bare git repository with a single commit on the
2532
// default branch. It returns the path to the bare repo directory.
2633
func initBareRepo(t *testing.T) string {

internal/git/cli/puller.go

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,40 @@ func NewPuller() *Puller {
1717
}
1818

1919
func (c *Puller) Pull(ctx context.Context, opts git.PullOptions) (string, error) {
20+
pinned := false
2021
if opts.Branch != "" {
22+
// Best-effort: surface newly-published tags before checkout.
23+
fetchCmd := exec.CommandContext(ctx, "git", "fetch", "--tags", "--force")
24+
fetchCmd.Dir = opts.Dir
25+
fetchCmd.Env = append(fetchCmd.Environ(), "GIT_TERMINAL_PROMPT=0")
26+
_ = fetchCmd.Run()
27+
2128
checkoutCmd := exec.CommandContext(ctx, "git", "checkout", opts.Branch)
2229
checkoutCmd.Dir = opts.Dir
2330
checkoutCmd.Env = append(checkoutCmd.Environ(), "GIT_TERMINAL_PROMPT=0")
2431
if err := checkoutCmd.Run(); err != nil {
2532
return "", fmt.Errorf("git checkout %s: %w", opts.Branch, err)
2633
}
34+
35+
// Detached HEAD means a tag/SHA pin; skip pull so HEAD stays put.
36+
symCmd := exec.CommandContext(ctx, "git", "symbolic-ref", "-q", "HEAD")
37+
symCmd.Dir = opts.Dir
38+
symCmd.Env = append(symCmd.Environ(), "GIT_TERMINAL_PROMPT=0")
39+
pinned = symCmd.Run() != nil
2740
}
2841

29-
// git pull
30-
pullCmd := exec.CommandContext(ctx, "git", "pull", "--rebase=false")
31-
pullCmd.Dir = opts.Dir
32-
pullCmd.Env = append(pullCmd.Environ(), "GIT_TERMINAL_PROMPT=0")
33-
out, err := pullCmd.CombinedOutput()
34-
if err != nil {
35-
return strings.TrimSpace(string(out)), err
42+
var out []byte
43+
if pinned {
44+
out = []byte(fmt.Sprintf("pinned to %s", opts.Branch))
45+
} else {
46+
pullCmd := exec.CommandContext(ctx, "git", "pull", "--rebase=false")
47+
pullCmd.Dir = opts.Dir
48+
pullCmd.Env = append(pullCmd.Environ(), "GIT_TERMINAL_PROMPT=0")
49+
var err error
50+
out, err = pullCmd.CombinedOutput()
51+
if err != nil {
52+
return strings.TrimSpace(string(out)), err
53+
}
3654
}
3755

3856
// Submodules are best-effort: a plugin whose submodule references an

internal/git/cli/puller_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package cli_test
33
import (
44
"context"
55
"os"
6+
"os/exec"
67
"path/filepath"
8+
"strings"
79
"testing"
810

911
"github.com/tmuxpack/tpack/internal/git"
@@ -103,3 +105,87 @@ func TestPuller_PullWithBranch(t *testing.T) {
103105
t.Fatalf("expected feature.txt after pull with branch: %v", err)
104106
}
105107
}
108+
109+
// revParse returns the resolved SHA of HEAD (or any ref) for a repo.
110+
func revParse(t *testing.T, dir, ref string) string {
111+
t.Helper()
112+
out, err := exec.CommandContext(context.Background(),
113+
"git", "-C", dir, "rev-parse", ref).Output()
114+
if err != nil {
115+
t.Fatalf("rev-parse %s in %s: %v", ref, dir, err)
116+
}
117+
return strings.TrimSpace(string(out))
118+
}
119+
120+
func TestPuller_PullSkippedOnTag(t *testing.T) {
121+
if testing.Short() {
122+
t.Skip("skipping git CLI test in short mode")
123+
}
124+
125+
bare := initBareRepo(t)
126+
127+
// Tag the initial commit and push the tag upstream.
128+
tagger := cloneLocal(t, bare)
129+
runGit(t, tagger, "tag", "v1.0.0")
130+
runGit(t, tagger, "push", "origin", "v1.0.0")
131+
tagSHA := revParse(t, tagger, "v1.0.0")
132+
133+
// Clone fresh, then check out the tag so HEAD is detached.
134+
clone := cloneLocal(t, bare)
135+
runGit(t, clone, "fetch", "--tags")
136+
runGit(t, clone, "checkout", "v1.0.0")
137+
138+
// Push a new commit upstream so a naive `git pull` would fast-forward.
139+
addCommitToBare(t, bare, "after-tag.txt")
140+
141+
puller := gitcli.NewPuller()
142+
_, err := puller.Pull(context.Background(), git.PullOptions{
143+
Dir: clone,
144+
Branch: "v1.0.0",
145+
})
146+
if err != nil {
147+
t.Fatalf("Pull pinned to tag returned error: %v", err)
148+
}
149+
150+
if got := revParse(t, clone, "HEAD"); got != tagSHA {
151+
t.Fatalf("HEAD moved off tag: HEAD=%s, want %s", got, tagSHA)
152+
}
153+
154+
// The new file must NOT be present; pull should have been skipped.
155+
if _, err := os.Stat(filepath.Join(clone, "after-tag.txt")); err == nil {
156+
t.Fatal("after-tag.txt should not exist; pull should have been skipped on detached HEAD")
157+
}
158+
}
159+
160+
func TestPuller_PullSkippedOnCommitSHA(t *testing.T) {
161+
if testing.Short() {
162+
t.Skip("skipping git CLI test in short mode")
163+
}
164+
165+
bare := initBareRepo(t)
166+
167+
// Capture the initial commit SHA and check it out (detached HEAD).
168+
clone := cloneLocal(t, bare)
169+
sha := revParse(t, clone, "HEAD")
170+
runGit(t, clone, "checkout", sha)
171+
172+
// Push a new commit upstream so a naive `git pull` would fast-forward.
173+
addCommitToBare(t, bare, "after-sha.txt")
174+
175+
puller := gitcli.NewPuller()
176+
_, err := puller.Pull(context.Background(), git.PullOptions{
177+
Dir: clone,
178+
Branch: sha,
179+
})
180+
if err != nil {
181+
t.Fatalf("Pull pinned to SHA returned error: %v", err)
182+
}
183+
184+
if got := revParse(t, clone, "HEAD"); got != sha {
185+
t.Fatalf("HEAD moved off pinned SHA: HEAD=%s, want %s", got, sha)
186+
}
187+
188+
if _, err := os.Stat(filepath.Join(clone, "after-sha.txt")); err == nil {
189+
t.Fatal("after-sha.txt should not exist; pull should have been skipped on detached HEAD")
190+
}
191+
}

internal/git/cli/refs.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package cli
2+
3+
import "regexp"
4+
5+
var commitSHArgx = regexp.MustCompile(`^[0-9a-fA-F]{7,40}$`)
6+
7+
func looksLikeCommitSHA(ref string) bool {
8+
return commitSHArgx.MatchString(ref)
9+
}

0 commit comments

Comments
 (0)