diff --git a/cmd/alias.go b/cmd/alias.go new file mode 100644 index 0000000..a3ecf8f --- /dev/null +++ b/cmd/alias.go @@ -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 +} + +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 { + 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+" "), cfg.ColorCyan("gh stack ")) + + // 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) +} diff --git a/cmd/alias_test.go b/cmd/alias_test.go new file mode 100644 index 0000000..f116504 --- /dev/null +++ b/cmd/alias_test.go @@ -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") +} + +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) +} + +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) +} diff --git a/cmd/root.go b/cmd/root.go index db872f4..7426e1c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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)) diff --git a/cmd/root_test.go b/cmd/root_test.go index df3c427..a5bba22 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -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() {