-
Notifications
You must be signed in to change notification settings - Fork 5
add alias command #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
|
|
||
| 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 { | ||
|
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) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| } | ||
|
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) | ||
| } | ||
|
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) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.