Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions cmd/alias.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package cmd

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"

"github.com/github/gh-stack/internal/config"
"github.com/spf13/cobra"
)

const (
defaultAliasName = "gs"
wrapperMarkerLine = "# installed by github/gh-stack" // used to identify our own scripts
markedWrapperContent = "#!/bin/sh\n# installed by github/gh-stack\nexec gh stack \"$@\"\n"
)

var validAliasName = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)

func AliasCmd(cfg *config.Config) *cobra.Command {
var remove bool

cmd := &cobra.Command{
Use: "alias [name]",
Short: "Create a shell alias for gh stack",
Long: `Create a short command alias so you can run "gs [command]" instead of "gh stack [command]".

This installs a small wrapper script into ~/.local/bin/ that forwards all
arguments to "gh stack". The default alias name is "gs", but you can choose
any name by passing it as an argument.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := defaultAliasName
if len(args) > 0 {
name = args[0]
}
if err := validateAliasName(cfg, name); err != nil {
return err
}
if runtime.GOOS == "windows" {
return handleWindowsAlias(cfg, name, remove)
}
binDir, err := localBinDirFunc()
if err != nil {
cfg.Errorf("%s", err)
return ErrSilent
}
if remove {
return runAliasRemove(cfg, name, binDir)
}
return runAlias(cfg, name, binDir)
},
}

cmd.Flags().BoolVar(&remove, "remove", false, "Remove a previously created alias")

return cmd
}

// validateAliasName checks that name is a valid alias identifier.
func validateAliasName(cfg *config.Config, name string) error {
if !validAliasName.MatchString(name) {
cfg.Errorf("invalid alias name %q: must start with a letter and contain only letters, digits, hyphens, or underscores", name)
return ErrInvalidArgs
}
return nil
}

// handleWindowsAlias prints manual instructions since automatic alias
// management is not supported on Windows.
func handleWindowsAlias(cfg *config.Config, name string, remove bool) error {
if remove {
cfg.Infof("Automatic alias removal is not supported on Windows.")
cfg.Printf("Remove the %s.cmd file from your PATH manually.", name)
} else {
cfg.Infof("Automatic alias creation is not supported on Windows.")
cfg.Printf("You can create the alias manually by adding a batch file or PowerShell function.")
cfg.Printf("For example, create a file named %s.cmd on your PATH with:", name)
cfg.Printf(" @echo off")
cfg.Printf(" gh stack %%*")
}
return ErrSilent
Comment thread
skarim marked this conversation as resolved.
}

func runAlias(cfg *config.Config, name string, binDir string) error {
scriptPath := filepath.Join(binDir, name)

// Check if our wrapper already exists at this path.
if isOurWrapper(scriptPath) {
cfg.Successf("Alias %q is already installed at %s", name, scriptPath)
return nil
}

// Check for an existing command with this name.
if existing, err := exec.LookPath(name); err == nil {
cfg.Errorf("a command named %q already exists at %s", name, existing)
cfg.Printf("Choose a different alias name, for example: %s", cfg.ColorCyan("gh stack alias gst"))
return ErrInvalidArgs
}

// Guard against overwriting an existing file that isn't on PATH
if _, err := os.Stat(scriptPath); err == nil {
cfg.Errorf("a file already exists at %s", scriptPath)
cfg.Printf("Choose a different alias name, for example: %s", cfg.ColorCyan("gh stack alias gst"))
return ErrInvalidArgs
}

// Ensure the bin directory exists.
if err := os.MkdirAll(binDir, 0o755); err != nil {
cfg.Errorf("failed to create directory %s: %s", binDir, err)
return ErrSilent
}

// Write the wrapper script.
if err := os.WriteFile(scriptPath, []byte(markedWrapperContent), 0o755); err != nil {
Comment thread
skarim marked this conversation as resolved.
cfg.Errorf("failed to write %s: %s", scriptPath, err)
return ErrSilent
}

cfg.Successf("Created alias %q at %s", name, scriptPath)
cfg.Printf("You can now use %s instead of %s", cfg.ColorCyan(name+" <command>"), cfg.ColorCyan("gh stack <command>"))

// Warn if the bin directory is not in PATH.
if !dirInPath(binDir) {
cfg.Warningf("%s is not in your PATH", binDir)
cfg.Printf("Add it by appending this to your shell profile (~/.bashrc, ~/.zshrc, etc.):")
cfg.Printf(" export PATH=\"%s:$PATH\"", binDir)
}

return nil
}

func runAliasRemove(cfg *config.Config, name string, binDir string) error {
scriptPath := filepath.Join(binDir, name)

if !isOurWrapper(scriptPath) {
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
cfg.Errorf("no alias %q found at %s", name, scriptPath)
} else {
cfg.Errorf("%s exists but was not created by gh-stack; refusing to remove", scriptPath)
}
return ErrSilent
}

if err := os.Remove(scriptPath); err != nil {
cfg.Errorf("failed to remove %s: %s", scriptPath, err)
return ErrSilent
}

cfg.Successf("Removed alias %q from %s", name, scriptPath)
return nil
}

// localBinDirFunc returns the user-local binary directory (~/.local/bin).
// It is a variable so tests can override it.
var localBinDirFunc = func() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("could not determine home directory: %w", err)
}
return filepath.Join(home, ".local", "bin"), nil
}

// dirInPath reports whether dir is present in the system PATH.
func dirInPath(dir string) bool {
for _, p := range filepath.SplitList(os.Getenv("PATH")) {
if p == dir {
return true
}
}
return false
}

// isOurWrapper checks if the file at path is a wrapper script that we created.
func isOurWrapper(path string) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
return strings.Contains(string(data), wrapperMarkerLine)
}
195 changes: 195 additions & 0 deletions cmd/alias_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package cmd

import (
"os"
"path/filepath"
"runtime"
"testing"

"github.com/github/gh-stack/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAliasCmd_ValidatesName(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{"default", "gs", false},
{"alphanumeric", "gst2", false},
{"with-hyphen", "my-stack", false},
{"with-underscore", "my_stack", false},
{"starts-with-digit", "2gs", true},
{"has-spaces", "my stack", true},
{"has-slash", "my/stack", true},
{"empty", "", true},
{"special-chars", "gs!", true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, !tt.wantErr, validAliasName.MatchString(tt.input))
})
}
}

// skipWindows skips the current test on Windows since the alias command
// creates Unix shell scripts.
func skipWindows(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("alias command uses shell scripts; not supported on Windows")
}
}

// withTmpBinDir skips on Windows, overrides localBinDirFunc to use a temp
// directory, and restores it when the test completes.
func withTmpBinDir(t *testing.T) string {
t.Helper()
skipWindows(t)
tmpDir := t.TempDir()
orig := localBinDirFunc
localBinDirFunc = func() (string, error) { return tmpDir, nil }
t.Cleanup(func() { localBinDirFunc = orig })
return tmpDir
}

// testAliasName is a name unlikely to collide with real commands on PATH.
const testAliasName = "ghstacktest"

func TestRunAlias_CreatesWrapperScript(t *testing.T) {
tmpDir := withTmpBinDir(t)
cfg, _, _ := config.NewTestConfig()

err := runAlias(cfg, testAliasName, tmpDir)
require.NoError(t, err)

scriptPath := filepath.Join(tmpDir, testAliasName)
data, err := os.ReadFile(scriptPath)
require.NoError(t, err)
assert.Equal(t, markedWrapperContent, string(data))

info, err := os.Stat(scriptPath)
require.NoError(t, err)
assert.True(t, info.Mode()&0o111 != 0, "script should be executable")
}
Comment thread
skarim marked this conversation as resolved.

func TestRunAlias_Idempotent(t *testing.T) {
tmpDir := withTmpBinDir(t)
cfg, _, _ := config.NewTestConfig()

// First install
require.NoError(t, runAlias(cfg, testAliasName, tmpDir))
// Second install should succeed (idempotent)
require.NoError(t, runAlias(cfg, testAliasName, tmpDir))
}

func TestRunAlias_RejectsExistingCommand(t *testing.T) {
tmpDir := withTmpBinDir(t)
cfg, _, _ := config.NewTestConfig()

// "ls" exists on every Unix system
err := runAlias(cfg, "ls", tmpDir)
assert.ErrorIs(t, err, ErrInvalidArgs)
}
Comment thread
skarim marked this conversation as resolved.

func TestRunAliasRemove_RemovesWrapper(t *testing.T) {
tmpDir := withTmpBinDir(t)
cfg, _, _ := config.NewTestConfig()

require.NoError(t, runAlias(cfg, testAliasName, tmpDir))

scriptPath := filepath.Join(tmpDir, testAliasName)
require.FileExists(t, scriptPath)

require.NoError(t, runAliasRemove(cfg, testAliasName, tmpDir))
assert.NoFileExists(t, scriptPath)
}

func TestRunAliasRemove_RefusesNonOurScript(t *testing.T) {
tmpDir := withTmpBinDir(t)
cfg, _, _ := config.NewTestConfig()

// Create a file that isn't our wrapper
scriptPath := filepath.Join(tmpDir, testAliasName)
require.NoError(t, os.WriteFile(scriptPath, []byte("#!/bin/sh\necho hello\n"), 0o755))

err := runAliasRemove(cfg, testAliasName, tmpDir)
assert.Error(t, err)
assert.FileExists(t, scriptPath)
}

func TestRunAliasRemove_ErrorsWhenNotFound(t *testing.T) {
tmpDir := withTmpBinDir(t)
cfg, _, _ := config.NewTestConfig()

err := runAliasRemove(cfg, testAliasName, tmpDir)
assert.Error(t, err)
}

func TestIsOurWrapper(t *testing.T) {
tmpDir := t.TempDir()

ourPath := filepath.Join(tmpDir, "ours")
require.NoError(t, os.WriteFile(ourPath, []byte(markedWrapperContent), 0o755))
assert.True(t, isOurWrapper(ourPath))

otherPath := filepath.Join(tmpDir, "other")
require.NoError(t, os.WriteFile(otherPath, []byte("#!/bin/sh\necho hi\n"), 0o755))
assert.False(t, isOurWrapper(otherPath))

assert.False(t, isOurWrapper(filepath.Join(tmpDir, "nope")))
}

func TestDirInPath(t *testing.T) {
// Use a directory we know is in PATH on any platform.
found := false
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
if dirInPath(dir) {
found = true
break
}
}
assert.True(t, found, "expected at least one PATH entry to be found by dirInPath")
assert.False(t, dirInPath("/nonexistent/path/that/should/not/exist"))
}

func TestAliasCmd_RemoveFlagWiring(t *testing.T) {
tmpDir := withTmpBinDir(t)
cfg, _, _ := config.NewTestConfig()

// Install the alias first via runAlias so there's something to remove.
require.NoError(t, runAlias(cfg, testAliasName, tmpDir))
require.FileExists(t, filepath.Join(tmpDir, testAliasName))

// Now exercise the cobra command with --remove to verify flag plumbing.
cmd := AliasCmd(cfg)
cmd.SetArgs([]string{"--remove", testAliasName})
require.NoError(t, cmd.Execute())

assert.NoFileExists(t, filepath.Join(tmpDir, testAliasName))
}

func TestAliasCmd_WindowsReturnsError(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows-only test")
}

cfg, _, _ := config.NewTestConfig()

cmd := AliasCmd(cfg)
cmd.SetArgs([]string{testAliasName})
assert.Error(t, cmd.Execute())
}

func TestValidateAliasName(t *testing.T) {
cfg, _, _ := config.NewTestConfig()

assert.NoError(t, validateAliasName(cfg, "gs"))
assert.NoError(t, validateAliasName(cfg, "my-stack"))
assert.ErrorIs(t, validateAliasName(cfg, ""), ErrInvalidArgs)
assert.ErrorIs(t, validateAliasName(cfg, "2bad"), ErrInvalidArgs)
assert.ErrorIs(t, validateAliasName(cfg, "has space"), ErrInvalidArgs)
}
3 changes: 3 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ func RootCmd() *cobra.Command {
root.AddCommand(TopCmd(cfg))
root.AddCommand(BottomCmd(cfg))

// Alias
root.AddCommand(AliasCmd(cfg))

// Feedback
root.AddCommand(FeedbackCmd(cfg))

Expand Down
Loading
Loading