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
3 changes: 2 additions & 1 deletion cmd/prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ If a branch has unmerged commits locally, use --force to delete it anyway.`,
stack prune --dry-run`,
Run: func(cmd *cobra.Command, args []string) {
gitClient := git.NewGitClient()
githubClient := github.NewGitHubClient()
repo := github.ParseRepoFromURL(gitClient.GetRemoteURL("origin"))
githubClient := github.NewGitHubClient(repo)

if err := runPrune(gitClient, githubClient); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
Expand Down
3 changes: 2 additions & 1 deletion cmd/reparent.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ This is useful for:
newParent := args[0]

gitClient := git.NewGitClient()
githubClient := github.NewGitHubClient()
repo := github.ParseRepoFromURL(gitClient.GetRemoteURL("origin"))
githubClient := github.NewGitHubClient(repo)

if err := runReparent(gitClient, githubClient, newParent); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
Expand Down
3 changes: 2 additions & 1 deletion cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ This helps you visualize your stack and see which branches have PRs.`,
# feature-auth-tests *`,
Run: func(cmd *cobra.Command, args []string) {
gitClient := git.NewGitClient()
githubClient := github.NewGitHubClient()
repo := github.ParseRepoFromURL(gitClient.GetRemoteURL("origin"))
githubClient := github.NewGitHubClient(repo)

if err := runStatus(gitClient, githubClient); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
Expand Down
3 changes: 2 additions & 1 deletion cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ Uncommitted changes are automatically stashed and reapplied (using --autostash).
stack sync`,
Run: func(cmd *cobra.Command, args []string) {
gitClient := git.NewGitClient()
githubClient := github.NewGitHubClient()
repo := github.ParseRepoFromURL(gitClient.GetRemoteURL("origin"))
githubClient := github.NewGitHubClient(repo)

if err := runSync(gitClient, githubClient); err != nil {
// Don't print if error was already displayed with detailed message
Expand Down
3 changes: 2 additions & 1 deletion cmd/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ Use --prune to clean up worktrees for branches with merged PRs.`,
},
Run: func(cmd *cobra.Command, args []string) {
gitClient := git.NewGitClient()
githubClient := github.NewGitHubClient()
repo := github.ParseRepoFromURL(gitClient.GetRemoteURL("origin"))
githubClient := github.NewGitHubClient(repo)

var err error
if worktreePrune {
Expand Down
5 changes: 5 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -648,3 +648,8 @@ func (c *gitClient) ListWorktrees() ([]string, error) {

return paths, nil
}

// GetRemoteURL returns the URL of the specified remote (e.g., "origin")
func (c *gitClient) GetRemoteURL(remoteName string) string {
return c.runCmdMayFail("remote", "get-url", remoteName)
}
1 change: 1 addition & 0 deletions internal/git/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,5 @@ type GitClient interface {
AddWorktreeFromRemote(path, branch string) error
RemoveWorktree(path string) error
ListWorktrees() ([]string, error)
GetRemoteURL(remoteName string) string
}
51 changes: 48 additions & 3 deletions internal/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,60 @@ type PRInfo struct {
}

// githubClient implements the GitHubClient interface using exec.Command
type githubClient struct{}
type githubClient struct {
repo string // OWNER/REPO format, used with --repo flag
}

// NewGitHubClient creates a new GitHubClient implementation
func NewGitHubClient() GitHubClient {
return &githubClient{}
// repo should be in OWNER/REPO format (e.g., "javoire/stackinator")
func NewGitHubClient(repo string) GitHubClient {
return &githubClient{repo: repo}
}

// ParseRepoFromURL extracts OWNER/REPO from a git remote URL
// Supports formats:
// - git@github.com:owner/repo.git
// - https://github.com/owner/repo.git
// - git@ghe.spotify.net:owner/repo.git
// - https://ghe.spotify.net/owner/repo
func ParseRepoFromURL(remoteURL string) string {
remoteURL = strings.TrimSpace(remoteURL)
if remoteURL == "" {
return ""
}

// Remove .git suffix
remoteURL = strings.TrimSuffix(remoteURL, ".git")

// Handle SSH format: git@host:owner/repo
if strings.HasPrefix(remoteURL, "git@") {
parts := strings.SplitN(remoteURL, ":", 2)
if len(parts) == 2 {
return parts[1]
}
}

// Handle HTTPS format: https://host/owner/repo
if strings.HasPrefix(remoteURL, "https://") || strings.HasPrefix(remoteURL, "http://") {
// Find the path after the host
afterScheme := strings.TrimPrefix(remoteURL, "https://")
afterScheme = strings.TrimPrefix(afterScheme, "http://")
// Split host from path
slashIdx := strings.Index(afterScheme, "/")
if slashIdx != -1 {
return afterScheme[slashIdx+1:]
}
}

return ""
}

// runGH executes a gh CLI command and returns stdout
func (c *githubClient) runGH(args ...string) (string, error) {
// Add --repo flag if repo is set (ensures correct repo with multiple remotes)
if c.repo != "" {
args = append([]string{"--repo", c.repo}, args...)
}
if Verbose {
fmt.Printf(" [gh] %s\n", strings.Join(args, " "))
}
Expand Down
48 changes: 47 additions & 1 deletion internal/github/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,56 @@ func TestPRInfoParsing(t *testing.T) {
}

func TestNewGitHubClient(t *testing.T) {
client := NewGitHubClient()
client := NewGitHubClient("owner/repo")
assert.NotNil(t, client)
}

func TestParseRepoFromURL(t *testing.T) {
tests := []struct {
name string
url string
expected string
}{
{
name: "SSH format",
url: "git@github.com:javoire/stackinator.git",
expected: "javoire/stackinator",
},
{
name: "HTTPS format",
url: "https://github.com/javoire/stackinator.git",
expected: "javoire/stackinator",
},
{
name: "HTTPS without .git",
url: "https://github.com/javoire/stackinator",
expected: "javoire/stackinator",
},
{
name: "GHE SSH format",
url: "git@ghe.spotify.net:some-org/some-repo.git",
expected: "some-org/some-repo",
},
{
name: "GHE HTTPS format",
url: "https://ghe.spotify.net/some-org/some-repo",
expected: "some-org/some-repo",
},
{
name: "empty string",
url: "",
expected: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ParseRepoFromURL(tt.url)
assert.Equal(t, tt.expected, result)
})
}
}

// Note: More comprehensive tests would require mocking exec.Command or running actual gh CLI commands
// For unit tests focused on critical path, we rely on integration tests or testutil mocks

5 changes: 5 additions & 0 deletions internal/testutil/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,11 @@ func (m *MockGitClient) ListWorktrees() ([]string, error) {
return args.Get(0).([]string), args.Error(1)
}

func (m *MockGitClient) GetRemoteURL(remoteName string) string {
args := m.Called(remoteName)
return args.String(0)
}

// MockGitHubClient is a mock implementation of github.GitHubClient for testing
type MockGitHubClient struct {
mock.Mock
Expand Down