Skip to content

Commit facd16d

Browse files
authored
Merge pull request #9 from github/skarim/alias
add alias command
2 parents 32fd36c + 6a813d9 commit facd16d

File tree

4 files changed

+384
-1
lines changed

4 files changed

+384
-1
lines changed

cmd/alias.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"regexp"
9+
"runtime"
10+
"strings"
11+
12+
"github.com/github/gh-stack/internal/config"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
const (
17+
defaultAliasName = "gs"
18+
wrapperMarkerLine = "# installed by github/gh-stack" // used to identify our own scripts
19+
markedWrapperContent = "#!/bin/sh\n# installed by github/gh-stack\nexec gh stack \"$@\"\n"
20+
)
21+
22+
var validAliasName = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)
23+
24+
func AliasCmd(cfg *config.Config) *cobra.Command {
25+
var remove bool
26+
27+
cmd := &cobra.Command{
28+
Use: "alias [name]",
29+
Short: "Create a shell alias for gh stack",
30+
Long: `Create a short command alias so you can run "gs [command]" instead of "gh stack [command]".
31+
32+
This installs a small wrapper script into ~/.local/bin/ that forwards all
33+
arguments to "gh stack". The default alias name is "gs", but you can choose
34+
any name by passing it as an argument.`,
35+
Args: cobra.MaximumNArgs(1),
36+
RunE: func(cmd *cobra.Command, args []string) error {
37+
name := defaultAliasName
38+
if len(args) > 0 {
39+
name = args[0]
40+
}
41+
if err := validateAliasName(cfg, name); err != nil {
42+
return err
43+
}
44+
if runtime.GOOS == "windows" {
45+
return handleWindowsAlias(cfg, name, remove)
46+
}
47+
binDir, err := localBinDirFunc()
48+
if err != nil {
49+
cfg.Errorf("%s", err)
50+
return ErrSilent
51+
}
52+
if remove {
53+
return runAliasRemove(cfg, name, binDir)
54+
}
55+
return runAlias(cfg, name, binDir)
56+
},
57+
}
58+
59+
cmd.Flags().BoolVar(&remove, "remove", false, "Remove a previously created alias")
60+
61+
return cmd
62+
}
63+
64+
// validateAliasName checks that name is a valid alias identifier.
65+
func validateAliasName(cfg *config.Config, name string) error {
66+
if !validAliasName.MatchString(name) {
67+
cfg.Errorf("invalid alias name %q: must start with a letter and contain only letters, digits, hyphens, or underscores", name)
68+
return ErrInvalidArgs
69+
}
70+
return nil
71+
}
72+
73+
// handleWindowsAlias prints manual instructions since automatic alias
74+
// management is not supported on Windows.
75+
func handleWindowsAlias(cfg *config.Config, name string, remove bool) error {
76+
if remove {
77+
cfg.Infof("Automatic alias removal is not supported on Windows.")
78+
cfg.Printf("Remove the %s.cmd file from your PATH manually.", name)
79+
} else {
80+
cfg.Infof("Automatic alias creation is not supported on Windows.")
81+
cfg.Printf("You can create the alias manually by adding a batch file or PowerShell function.")
82+
cfg.Printf("For example, create a file named %s.cmd on your PATH with:", name)
83+
cfg.Printf(" @echo off")
84+
cfg.Printf(" gh stack %%*")
85+
}
86+
return ErrSilent
87+
}
88+
89+
func runAlias(cfg *config.Config, name string, binDir string) error {
90+
scriptPath := filepath.Join(binDir, name)
91+
92+
// Check if our wrapper already exists at this path.
93+
if isOurWrapper(scriptPath) {
94+
cfg.Successf("Alias %q is already installed at %s", name, scriptPath)
95+
return nil
96+
}
97+
98+
// Check for an existing command with this name.
99+
if existing, err := exec.LookPath(name); err == nil {
100+
cfg.Errorf("a command named %q already exists at %s", name, existing)
101+
cfg.Printf("Choose a different alias name, for example: %s", cfg.ColorCyan("gh stack alias gst"))
102+
return ErrInvalidArgs
103+
}
104+
105+
// Guard against overwriting an existing file that isn't on PATH
106+
if _, err := os.Stat(scriptPath); err == nil {
107+
cfg.Errorf("a file already exists at %s", scriptPath)
108+
cfg.Printf("Choose a different alias name, for example: %s", cfg.ColorCyan("gh stack alias gst"))
109+
return ErrInvalidArgs
110+
}
111+
112+
// Ensure the bin directory exists.
113+
if err := os.MkdirAll(binDir, 0o755); err != nil {
114+
cfg.Errorf("failed to create directory %s: %s", binDir, err)
115+
return ErrSilent
116+
}
117+
118+
// Write the wrapper script.
119+
if err := os.WriteFile(scriptPath, []byte(markedWrapperContent), 0o755); err != nil {
120+
cfg.Errorf("failed to write %s: %s", scriptPath, err)
121+
return ErrSilent
122+
}
123+
124+
cfg.Successf("Created alias %q at %s", name, scriptPath)
125+
cfg.Printf("You can now use %s instead of %s", cfg.ColorCyan(name+" <command>"), cfg.ColorCyan("gh stack <command>"))
126+
127+
// Warn if the bin directory is not in PATH.
128+
if !dirInPath(binDir) {
129+
cfg.Warningf("%s is not in your PATH", binDir)
130+
cfg.Printf("Add it by appending this to your shell profile (~/.bashrc, ~/.zshrc, etc.):")
131+
cfg.Printf(" export PATH=\"%s:$PATH\"", binDir)
132+
}
133+
134+
return nil
135+
}
136+
137+
func runAliasRemove(cfg *config.Config, name string, binDir string) error {
138+
scriptPath := filepath.Join(binDir, name)
139+
140+
if !isOurWrapper(scriptPath) {
141+
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
142+
cfg.Errorf("no alias %q found at %s", name, scriptPath)
143+
} else {
144+
cfg.Errorf("%s exists but was not created by gh-stack; refusing to remove", scriptPath)
145+
}
146+
return ErrSilent
147+
}
148+
149+
if err := os.Remove(scriptPath); err != nil {
150+
cfg.Errorf("failed to remove %s: %s", scriptPath, err)
151+
return ErrSilent
152+
}
153+
154+
cfg.Successf("Removed alias %q from %s", name, scriptPath)
155+
return nil
156+
}
157+
158+
// localBinDirFunc returns the user-local binary directory (~/.local/bin).
159+
// It is a variable so tests can override it.
160+
var localBinDirFunc = func() (string, error) {
161+
home, err := os.UserHomeDir()
162+
if err != nil {
163+
return "", fmt.Errorf("could not determine home directory: %w", err)
164+
}
165+
return filepath.Join(home, ".local", "bin"), nil
166+
}
167+
168+
// dirInPath reports whether dir is present in the system PATH.
169+
func dirInPath(dir string) bool {
170+
for _, p := range filepath.SplitList(os.Getenv("PATH")) {
171+
if p == dir {
172+
return true
173+
}
174+
}
175+
return false
176+
}
177+
178+
// isOurWrapper checks if the file at path is a wrapper script that we created.
179+
func isOurWrapper(path string) bool {
180+
data, err := os.ReadFile(path)
181+
if err != nil {
182+
return false
183+
}
184+
return strings.Contains(string(data), wrapperMarkerLine)
185+
}

cmd/alias_test.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"testing"
8+
9+
"github.com/github/gh-stack/internal/config"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestAliasCmd_ValidatesName(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
input string
18+
wantErr bool
19+
}{
20+
{"default", "gs", false},
21+
{"alphanumeric", "gst2", false},
22+
{"with-hyphen", "my-stack", false},
23+
{"with-underscore", "my_stack", false},
24+
{"starts-with-digit", "2gs", true},
25+
{"has-spaces", "my stack", true},
26+
{"has-slash", "my/stack", true},
27+
{"empty", "", true},
28+
{"special-chars", "gs!", true},
29+
}
30+
31+
for _, tt := range tests {
32+
t.Run(tt.name, func(t *testing.T) {
33+
assert.Equal(t, !tt.wantErr, validAliasName.MatchString(tt.input))
34+
})
35+
}
36+
}
37+
38+
// skipWindows skips the current test on Windows since the alias command
39+
// creates Unix shell scripts.
40+
func skipWindows(t *testing.T) {
41+
t.Helper()
42+
if runtime.GOOS == "windows" {
43+
t.Skip("alias command uses shell scripts; not supported on Windows")
44+
}
45+
}
46+
47+
// withTmpBinDir skips on Windows, overrides localBinDirFunc to use a temp
48+
// directory, and restores it when the test completes.
49+
func withTmpBinDir(t *testing.T) string {
50+
t.Helper()
51+
skipWindows(t)
52+
tmpDir := t.TempDir()
53+
orig := localBinDirFunc
54+
localBinDirFunc = func() (string, error) { return tmpDir, nil }
55+
t.Cleanup(func() { localBinDirFunc = orig })
56+
return tmpDir
57+
}
58+
59+
// testAliasName is a name unlikely to collide with real commands on PATH.
60+
const testAliasName = "ghstacktest"
61+
62+
func TestRunAlias_CreatesWrapperScript(t *testing.T) {
63+
tmpDir := withTmpBinDir(t)
64+
cfg, _, _ := config.NewTestConfig()
65+
66+
err := runAlias(cfg, testAliasName, tmpDir)
67+
require.NoError(t, err)
68+
69+
scriptPath := filepath.Join(tmpDir, testAliasName)
70+
data, err := os.ReadFile(scriptPath)
71+
require.NoError(t, err)
72+
assert.Equal(t, markedWrapperContent, string(data))
73+
74+
info, err := os.Stat(scriptPath)
75+
require.NoError(t, err)
76+
assert.True(t, info.Mode()&0o111 != 0, "script should be executable")
77+
}
78+
79+
func TestRunAlias_Idempotent(t *testing.T) {
80+
tmpDir := withTmpBinDir(t)
81+
cfg, _, _ := config.NewTestConfig()
82+
83+
// First install
84+
require.NoError(t, runAlias(cfg, testAliasName, tmpDir))
85+
// Second install should succeed (idempotent)
86+
require.NoError(t, runAlias(cfg, testAliasName, tmpDir))
87+
}
88+
89+
func TestRunAlias_RejectsExistingCommand(t *testing.T) {
90+
tmpDir := withTmpBinDir(t)
91+
cfg, _, _ := config.NewTestConfig()
92+
93+
// "ls" exists on every Unix system
94+
err := runAlias(cfg, "ls", tmpDir)
95+
assert.ErrorIs(t, err, ErrInvalidArgs)
96+
}
97+
98+
func TestRunAliasRemove_RemovesWrapper(t *testing.T) {
99+
tmpDir := withTmpBinDir(t)
100+
cfg, _, _ := config.NewTestConfig()
101+
102+
require.NoError(t, runAlias(cfg, testAliasName, tmpDir))
103+
104+
scriptPath := filepath.Join(tmpDir, testAliasName)
105+
require.FileExists(t, scriptPath)
106+
107+
require.NoError(t, runAliasRemove(cfg, testAliasName, tmpDir))
108+
assert.NoFileExists(t, scriptPath)
109+
}
110+
111+
func TestRunAliasRemove_RefusesNonOurScript(t *testing.T) {
112+
tmpDir := withTmpBinDir(t)
113+
cfg, _, _ := config.NewTestConfig()
114+
115+
// Create a file that isn't our wrapper
116+
scriptPath := filepath.Join(tmpDir, testAliasName)
117+
require.NoError(t, os.WriteFile(scriptPath, []byte("#!/bin/sh\necho hello\n"), 0o755))
118+
119+
err := runAliasRemove(cfg, testAliasName, tmpDir)
120+
assert.Error(t, err)
121+
assert.FileExists(t, scriptPath)
122+
}
123+
124+
func TestRunAliasRemove_ErrorsWhenNotFound(t *testing.T) {
125+
tmpDir := withTmpBinDir(t)
126+
cfg, _, _ := config.NewTestConfig()
127+
128+
err := runAliasRemove(cfg, testAliasName, tmpDir)
129+
assert.Error(t, err)
130+
}
131+
132+
func TestIsOurWrapper(t *testing.T) {
133+
tmpDir := t.TempDir()
134+
135+
ourPath := filepath.Join(tmpDir, "ours")
136+
require.NoError(t, os.WriteFile(ourPath, []byte(markedWrapperContent), 0o755))
137+
assert.True(t, isOurWrapper(ourPath))
138+
139+
otherPath := filepath.Join(tmpDir, "other")
140+
require.NoError(t, os.WriteFile(otherPath, []byte("#!/bin/sh\necho hi\n"), 0o755))
141+
assert.False(t, isOurWrapper(otherPath))
142+
143+
assert.False(t, isOurWrapper(filepath.Join(tmpDir, "nope")))
144+
}
145+
146+
func TestDirInPath(t *testing.T) {
147+
// Use a directory we know is in PATH on any platform.
148+
found := false
149+
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
150+
if dirInPath(dir) {
151+
found = true
152+
break
153+
}
154+
}
155+
assert.True(t, found, "expected at least one PATH entry to be found by dirInPath")
156+
assert.False(t, dirInPath("/nonexistent/path/that/should/not/exist"))
157+
}
158+
159+
func TestAliasCmd_RemoveFlagWiring(t *testing.T) {
160+
tmpDir := withTmpBinDir(t)
161+
cfg, _, _ := config.NewTestConfig()
162+
163+
// Install the alias first via runAlias so there's something to remove.
164+
require.NoError(t, runAlias(cfg, testAliasName, tmpDir))
165+
require.FileExists(t, filepath.Join(tmpDir, testAliasName))
166+
167+
// Now exercise the cobra command with --remove to verify flag plumbing.
168+
cmd := AliasCmd(cfg)
169+
cmd.SetArgs([]string{"--remove", testAliasName})
170+
require.NoError(t, cmd.Execute())
171+
172+
assert.NoFileExists(t, filepath.Join(tmpDir, testAliasName))
173+
}
174+
175+
func TestAliasCmd_WindowsReturnsError(t *testing.T) {
176+
if runtime.GOOS != "windows" {
177+
t.Skip("Windows-only test")
178+
}
179+
180+
cfg, _, _ := config.NewTestConfig()
181+
182+
cmd := AliasCmd(cfg)
183+
cmd.SetArgs([]string{testAliasName})
184+
assert.Error(t, cmd.Execute())
185+
}
186+
187+
func TestValidateAliasName(t *testing.T) {
188+
cfg, _, _ := config.NewTestConfig()
189+
190+
assert.NoError(t, validateAliasName(cfg, "gs"))
191+
assert.NoError(t, validateAliasName(cfg, "my-stack"))
192+
assert.ErrorIs(t, validateAliasName(cfg, ""), ErrInvalidArgs)
193+
assert.ErrorIs(t, validateAliasName(cfg, "2bad"), ErrInvalidArgs)
194+
assert.ErrorIs(t, validateAliasName(cfg, "has space"), ErrInvalidArgs)
195+
}

cmd/root.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ func RootCmd() *cobra.Command {
4747
root.AddCommand(TopCmd(cfg))
4848
root.AddCommand(BottomCmd(cfg))
4949

50+
// Alias
51+
root.AddCommand(AliasCmd(cfg))
52+
5053
// Feedback
5154
root.AddCommand(FeedbackCmd(cfg))
5255

0 commit comments

Comments
 (0)