Skip to content
Open
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
4 changes: 3 additions & 1 deletion cmd/amend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/ejoffe/spr/git/realgit"
"github.com/ejoffe/spr/github/githubclient"
"github.com/ejoffe/spr/spr"
"github.com/ejoffe/spr/vcs"
"github.com/jessevdk/go-flags"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
Expand Down Expand Up @@ -62,7 +63,8 @@ func main() {
client := githubclient.NewGitHubClient(ctx, cfg)
gitcmd = realgit.NewGitCmd(cfg)

sd := spr.NewStackedPR(cfg, client, gitcmd)
vcsOps := vcs.NewVCSOperations(cfg, gitcmd)
sd := spr.NewStackedPR(cfg, client, gitcmd, vcsOps)
sd.AmendCommit(ctx)

if opts.Update {
Expand Down
41 changes: 40 additions & 1 deletion cmd/spr/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"fmt"
"os"
"os/exec"
"strings"

"github.com/ejoffe/rake"
Expand All @@ -13,6 +14,7 @@ import (
"github.com/ejoffe/spr/git/realgit"
"github.com/ejoffe/spr/github/githubclient"
"github.com/ejoffe/spr/spr"
"github.com/ejoffe/spr/vcs"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"

Expand Down Expand Up @@ -88,9 +90,21 @@ func main() {
}
gitcmd = realgit.NewGitCmd(cfg)

// Check for --no-jj flag or SPR_NOJJ env var before creating VCS operations.
// This must happen before app.Run() since vcsOps is created here.
for _, arg := range os.Args[1:] {
if arg == "--no-jj" {
cfg.User.NoJJ = true
}
}
if os.Getenv("SPR_NOJJ") == "true" {
cfg.User.NoJJ = true
}

ctx := context.Background()
client := githubclient.NewGitHubClient(ctx, cfg)
stackedpr := spr.NewStackedPR(cfg, client, gitcmd)
vcsOps := vcs.NewVCSOperations(cfg, gitcmd)
stackedpr := spr.NewStackedPR(cfg, client, gitcmd, vcsOps)

detailFlag := &cli.BoolFlag{
Name: "detail",
Expand Down Expand Up @@ -146,6 +160,12 @@ VERSION: fork of {{.Version}}
Value: false,
Usage: "Show runtime debug info",
},
&cli.BoolFlag{
Name: "no-jj",
Value: false,
Usage: "Disable jj (Jujutsu) mode even in jj-colocated repos",
EnvVars: []string{"SPR_NOJJ"},
},
},
Before: func(c *cli.Context) error {
if c.IsSet("debug") {
Expand Down Expand Up @@ -307,6 +327,25 @@ VERSION: fork of {{.Version}}
return nil
},
},
{
Name: "jj-setup",
Usage: "Register 'jj spr' alias for use in Jujutsu repos",
Action: func(c *cli.Context) error {
cmd := exec.Command("jj", "config", "set", "--user",
"aliases.spr", `["util", "exec", "--", "git-spr"]`)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
return cli.Exit(fmt.Sprintf("Failed to set jj alias: %s", err), 1)
}
fmt.Println("jj alias registered. You can now use:")
fmt.Println(" jj spr update")
fmt.Println(" jj spr status")
fmt.Println(" jj spr merge")
return nil
},
},
{
Name: "version",
Usage: "Show version info",
Expand Down
5 changes: 5 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ type RepoConfig struct {

ForceFetchTags bool `default:"false" yaml:"forceFetchTags"`

MultiCommitPRs bool `default:"false" yaml:"multiCommitPRs"`
ConcatCommitMessages bool `default:"true" yaml:"concatCommitMessages"`

ShowPrTitlesInStack bool `default:"false" yaml:"showPrTitlesInStack"`
ShowStackNumberInTitle bool `default:"false" yaml:"showStackNumberInTitle"`
BranchPushIndividually bool `default:"false" yaml:"branchPushIndividually"`
}

Expand All @@ -53,6 +57,7 @@ type UserConfig struct {
CreateDraftPRs bool `default:"false" yaml:"createDraftPRs"`
PreserveTitleAndBody bool `default:"false" yaml:"preserveTitleAndBody"`
NoRebase bool `default:"false" yaml:"noRebase"`
NoJJ bool `default:"false" yaml:"noJJ"`
DeleteMergedBranches bool `default:"false" yaml:"deleteMergedBranches"`
ShortPRLink bool `default:"false" yaml:"shortPRLink"`
ShowCommitID bool `default:"false" yaml:"showCommitID"`
Expand Down
1 change: 1 addition & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func TestDefaultConfig(t *testing.T) {
PRTemplatePath: "",
PRTemplateInsertStart: "",
PRTemplateInsertEnd: "",
ConcatCommitMessages: true,
ShowPrTitlesInStack: false,
},
User: &UserConfig{
Expand Down
8 changes: 8 additions & 0 deletions git/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ type Commit struct {
// CommitHash is the git commit hash, this gets updated everytime the commit is amended.
CommitHash string

// ChangeID is the jj (Jujutsu) change ID. Only populated in jj-colocated repos.
// Unlike CommitHash, the ChangeID is stable across rewrites done via jj commands.
ChangeID string

// Subject is the subject of the commit message.
Subject string

Expand All @@ -28,4 +32,8 @@ type Commit struct {

// WIP is true if the commit is still work in progress.
WIP bool

// Branches holds local branch names (git) or bookmark names (jj) pointing at this commit.
// Only populated when MultiCommitPRs is enabled.
Branches []string
}
50 changes: 50 additions & 0 deletions git/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,56 @@ func BranchNameFromCommit(cfg *config.Config, commit Commit) string {

var BranchNameRegex = regexp.MustCompile(`spr/([a-zA-Z0-9_\-/\.]+)/([a-f0-9]{8})$`)

// IsSPRBranch returns true if branchName is an spr-managed remote branch (e.g. "spr/main/abc12345").
func IsSPRBranch(branchName string) bool {
return BranchNameRegex.MatchString(branchName)
}

// GetLocalBranchMap returns a map from commit hash to local branch names.
// It filters out spr/* branches and remote tracking branches.
func GetLocalBranchMap(gitcmd GitInterface) map[string][]string {
var output string
err := gitcmd.Git("for-each-ref --format=%(objectname) %(refname:short) refs/heads/", &output)
if err != nil {
return nil
}
result := make(map[string][]string)
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
continue
}
hash := parts[0]
branch := parts[1]
if IsSPRBranch(branch) {
continue
}
result[hash] = append(result[hash], branch)
}
return result
}

// AnnotateCommitsWithBranches populates the Branches field on each commit
// using the given branch map. It excludes the target branch name.
func AnnotateCommitsWithBranches(commits []Commit, branchMap map[string][]string, targetBranch string) {
if branchMap == nil {
return
}
for i := range commits {
branches := branchMap[commits[i].CommitHash]
var filtered []string
for _, b := range branches {
if b != targetBranch && !IsSPRBranch(b) {
filtered = append(filtered, b)
}
}
commits[i].Branches = filtered
}
}

// GetLocalTopCommit returns the top unmerged commit in the stack
//
// return nil if there are no unmerged commits in the stack
Expand Down
43 changes: 42 additions & 1 deletion git/helpers_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,47 @@
package git

import "testing"
import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestIsSPRBranch(t *testing.T) {
assert.True(t, IsSPRBranch("spr/main/deadbeef"))
assert.True(t, IsSPRBranch("spr/master/abc12345"))
assert.False(t, IsSPRBranch("my-feature"))
assert.False(t, IsSPRBranch("main"))
assert.False(t, IsSPRBranch("spr-related"))
}

func TestAnnotateCommitsWithBranches(t *testing.T) {
commits := []Commit{
{CommitHash: "hash1", CommitID: "00000001"},
{CommitHash: "hash2", CommitID: "00000002"},
{CommitHash: "hash3", CommitID: "00000003"},
}

branchMap := map[string][]string{
"hash1": {"feature-a", "spr/main/abc12345"},
"hash2": {"main"},
"hash3": {"feature-b"},
}

AnnotateCommitsWithBranches(commits, branchMap, "main")

// feature-a kept, spr branch filtered
assert.Equal(t, []string{"feature-a"}, commits[0].Branches)
// main filtered (target branch)
assert.Len(t, commits[1].Branches, 0)
// feature-b kept
assert.Equal(t, []string{"feature-b"}, commits[2].Branches)
}

func TestAnnotateCommitsWithBranches_NilMap(t *testing.T) {
commits := []Commit{{CommitHash: "hash1", CommitID: "00000001"}}
AnnotateCommitsWithBranches(commits, nil, "main")
assert.Nil(t, commits[0].Branches)
}

func TestBranchNameRegex(t *testing.T) {
tests := []struct {
Expand Down
6 changes: 6 additions & 0 deletions git/mockgit/mockgit.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ func (m *Mock) ExpectFetch() {
m.expect("git rebase origin/master --autostash")
}

func (m *Mock) ExpectFetchTags() {
m.expect("git fetch --tags --force")
m.expect("git rebase origin/master --autostash")
}

func (m *Mock) ExpectDeleteBranch(branchName string) {
m.expect(fmt.Sprintf("git DeleteRemoteBranch(%s)", branchName))
}
Expand Down Expand Up @@ -117,6 +122,7 @@ func (m *Mock) ExpectLocalBranch(name string) {
m.expect("git branch --no-color").respond(name)
}


func (m *Mock) expect(cmd string, args ...interface{}) *Mock {
m.expectedCmd = append(m.expectedCmd, fmt.Sprintf(cmd, args...))
m.response = append(m.response, &commitResponse{valid: false})
Expand Down
23 changes: 19 additions & 4 deletions github/githubclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ type client struct {
api genclient.Client
}

func (c *client) GetInfo(ctx context.Context, gitcmd git.GitInterface) *github.GitHubInfo {
func (c *client) GetInfo(ctx context.Context, gitcmd git.GitInterface, localCommits []git.Commit) *github.GitHubInfo {
if c.config.User.LogGitHubCalls {
fmt.Printf("> github fetch pull requests\n")
}
Expand All @@ -200,7 +200,7 @@ func (c *client) GetInfo(ctx context.Context, gitcmd git.GitInterface) *github.G
}

targetBranch := c.config.Repo.GitHubBranch
localCommitStack := git.GetLocalCommitStack(c.config, gitcmd)
localCommitStack := localCommits

pullRequests := matchPullRequestStack(c.config.Repo, targetBranch, localCommitStack, pullRequestConnection)
for _, pr := range pullRequests {
Expand Down Expand Up @@ -378,6 +378,21 @@ func (c *client) GetAssignableUsers(ctx context.Context) []github.RepoAssignee {
return users
}

// formatTitle adds a [Stack N/M] prefix to the title if configured.
func (c *client) formatTitle(title string, commit git.Commit, info *github.GitHubInfo) string {
if !c.config.Repo.ShowStackNumberInTitle {
return title
}
total := len(info.PullRequests)
for i, pr := range info.PullRequests {
if pr.Commit.CommitID == commit.CommitID {
return fmt.Sprintf("[Stack %d/%d] %s", i+1, total, title)
}
}
// Commit not yet in PR list (new PR being created) — count it
return fmt.Sprintf("[Stack %d/%d] %s", total+1, total+1, title)
}

func (c *client) CreatePullRequest(ctx context.Context, gitcmd git.GitInterface,
info *github.GitHubInfo, commit git.Commit, prevCommit *git.Commit) *github.PullRequest {

Expand All @@ -398,7 +413,7 @@ func (c *client) CreatePullRequest(ctx context.Context, gitcmd git.GitInterface,
RepositoryId: info.RepositoryID,
BaseRefName: baseRefName,
HeadRefName: headRefName,
Title: templatizer.Title(info, commit),
Title: c.formatTitle(templatizer.Title(info, commit), commit, info),
Body: &body,
Draft: &c.config.User.CreateDraftPRs,
})
Expand Down Expand Up @@ -445,7 +460,7 @@ func (c *client) UpdatePullRequest(ctx context.Context, gitcmd git.GitInterface,
Interface("PR", pr).Msg("UpdatePullRequest")

templatizer := config_fetcher.PRTemplatizer(c.config, gitcmd)
title := templatizer.Title(info, commit)
title := c.formatTitle(templatizer.Title(info, commit), commit, info)
body := templatizer.Body(info, commit, pr)
input := genclient.UpdatePullRequestInput{
PullRequestId: pr.ID,
Expand Down
61 changes: 61 additions & 0 deletions github/githubclient/client_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package githubclient

import (
"context"
"testing"

"github.com/ejoffe/spr/config"
Expand Down Expand Up @@ -528,3 +529,63 @@ func TestMatchPullRequestStack(t *testing.T) {
})
}
}

// TestGetInfoShouldAcceptLocalCommits verifies that GetInfo accepts a
// localCommits parameter so the caller can provide commits from VCSOperations
// (e.g. jj log) instead of GetInfo fetching them via git.GetLocalCommitStack.
//
// RED: currently GetInfo only takes (ctx, gitcmd) — no commits parameter.
// GREEN: after fix, GetInfo takes (ctx, gitcmd, localCommits).
func TestGetInfoShouldAcceptLocalCommits(t *testing.T) {
// Define the interface we expect GetInfo to satisfy after the fix.
type getInfoWithCommits interface {
GetInfo(ctx context.Context, gitcmd git.GitInterface, localCommits []git.Commit) *github.GitHubInfo
}

cfg := config.EmptyConfig()
c := &client{config: cfg}

_, ok := interface{}(c).(getInfoWithCommits)
require.True(t, ok,
"GetInfo should accept a localCommits []git.Commit parameter so jj mode can provide commits")
}

func TestFormatTitle_StackNumber(t *testing.T) {
prs := []*github.PullRequest{
{Commit: git.Commit{CommitID: "aaa"}},
{Commit: git.Commit{CommitID: "bbb"}},
{Commit: git.Commit{CommitID: "ccc"}},
}
info := &github.GitHubInfo{PullRequests: prs}

t.Run("disabled", func(t *testing.T) {
cfg := config.EmptyConfig()
c := &client{config: cfg}
got := c.formatTitle("my title", git.Commit{CommitID: "bbb"}, info)
require.Equal(t, "my title", got)
})

t.Run("enabled_middle", func(t *testing.T) {
cfg := config.EmptyConfig()
cfg.Repo.ShowStackNumberInTitle = true
c := &client{config: cfg}
got := c.formatTitle("my title", git.Commit{CommitID: "bbb"}, info)
require.Equal(t, "[Stack 2/3] my title", got)
})

t.Run("enabled_first", func(t *testing.T) {
cfg := config.EmptyConfig()
cfg.Repo.ShowStackNumberInTitle = true
c := &client{config: cfg}
got := c.formatTitle("my title", git.Commit{CommitID: "aaa"}, info)
require.Equal(t, "[Stack 1/3] my title", got)
})

t.Run("enabled_new_commit", func(t *testing.T) {
cfg := config.EmptyConfig()
cfg.Repo.ShowStackNumberInTitle = true
c := &client{config: cfg}
got := c.formatTitle("my title", git.Commit{CommitID: "zzz"}, info)
require.Equal(t, "[Stack 4/4] my title", got)
})
}
Loading