Skip to content

Commit 2f228a6

Browse files
committed
command to add alias
1 parent 32fd36c commit 2f228a6

4 files changed

Lines changed: 346 additions & 1 deletion

File tree

cmd/alias.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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+
// Ensure the bin directory exists.
106+
if err := os.MkdirAll(binDir, 0o755); err != nil {
107+
cfg.Errorf("failed to create directory %s: %s", binDir, err)
108+
return ErrSilent
109+
}
110+
111+
// Write the wrapper script.
112+
if err := os.WriteFile(scriptPath, []byte(markedWrapperContent), 0o755); err != nil {
113+
cfg.Errorf("failed to write %s: %s", scriptPath, err)
114+
return ErrSilent
115+
}
116+
117+
cfg.Successf("Created alias %q at %s", name, scriptPath)
118+
cfg.Printf("You can now use %s instead of %s", cfg.ColorCyan(name+" <command>"), cfg.ColorCyan("gh stack <command>"))
119+
120+
// Warn if the bin directory is not in PATH.
121+
if !dirInPath(binDir) {
122+
cfg.Warningf("%s is not in your PATH", binDir)
123+
cfg.Printf("Add it by appending this to your shell profile (~/.bashrc, ~/.zshrc, etc.):")
124+
cfg.Printf(" export PATH=\"%s:$PATH\"", binDir)
125+
}
126+
127+
return nil
128+
}
129+
130+
func runAliasRemove(cfg *config.Config, name string, binDir string) error {
131+
scriptPath := filepath.Join(binDir, name)
132+
133+
if !isOurWrapper(scriptPath) {
134+
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
135+
cfg.Errorf("no alias %q found at %s", name, scriptPath)
136+
} else {
137+
cfg.Errorf("%s exists but was not created by gh-stack; refusing to remove", scriptPath)
138+
}
139+
return ErrSilent
140+
}
141+
142+
if err := os.Remove(scriptPath); err != nil {
143+
cfg.Errorf("failed to remove %s: %s", scriptPath, err)
144+
return ErrSilent
145+
}
146+
147+
cfg.Successf("Removed alias %q from %s", name, scriptPath)
148+
return nil
149+
}
150+
151+
// localBinDirFunc returns the user-local binary directory (~/.local/bin).
152+
// It is a variable so tests can override it.
153+
var localBinDirFunc = func() (string, error) {
154+
home, err := os.UserHomeDir()
155+
if err != nil {
156+
return "", fmt.Errorf("could not determine home directory: %w", err)
157+
}
158+
return filepath.Join(home, ".local", "bin"), nil
159+
}
160+
161+
// dirInPath reports whether dir is present in the system PATH.
162+
func dirInPath(dir string) bool {
163+
for _, p := range filepath.SplitList(os.Getenv("PATH")) {
164+
if p == dir {
165+
return true
166+
}
167+
}
168+
return false
169+
}
170+
171+
// isOurWrapper checks if the file at path is a wrapper script that we created.
172+
func isOurWrapper(path string) bool {
173+
data, err := os.ReadFile(path)
174+
if err != nil {
175+
return false
176+
}
177+
return strings.Contains(string(data), wrapperMarkerLine)
178+
}

cmd/alias_test.go

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

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

cmd/root_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88

99
func TestRootCmd_SubcommandRegistration(t *testing.T) {
1010
root := RootCmd()
11-
expected := []string{"init", "add", "checkout", "push", "sync", "unstack", "merge", "view", "rebase", "up", "down", "top", "bottom", "feedback"}
11+
expected := []string{"init", "add", "checkout", "push", "sync", "unstack", "merge", "view", "rebase", "up", "down", "top", "bottom", "alias", "feedback"}
1212

1313
registered := make(map[string]bool)
1414
for _, cmd := range root.Commands() {

0 commit comments

Comments
 (0)