Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
178 changes: 178 additions & 0 deletions cmd/alias.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
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
}

// 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)
}
164 changes: 164 additions & 0 deletions cmd/alias_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package cmd

import (
"os"
"path/filepath"
"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))
})
}
}

// withTmpBinDir overrides localBinDirFunc to use a temp directory and restores
// it when the test completes.
func withTmpBinDir(t *testing.T) string {
t.Helper()
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) {
assert.True(t, dirInPath("/usr/bin") || dirInPath("/bin"), "expected at least /usr/bin or /bin in PATH")
assert.False(t, dirInPath("/nonexistent/path/that/should/not/exist"))
Comment thread
skarim marked this conversation as resolved.
Outdated
}

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 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
2 changes: 1 addition & 1 deletion cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (

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

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