├── .gitignore ├── LICENSE ├── README.md ├── assets ├── demo.gif ├── demo2.gif └── demo3.gif ├── cmd ├── root.go ├── script_utils.go └── update.go ├── go.mod ├── go.sum └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | .fj.toml 2 | 3 | fj 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Chih-Wei Chang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fj 2 | 3 | GitHub Stacked PR with JJ 4 | 5 | ## Install 6 | 7 | ``` 8 | go install github.com/lazywei/fj@latest 9 | ``` 10 | 11 | ## Usage 12 | 13 | ### Create branches / PRs 14 | 15 | The `fj` command will create a branch for each stacked commit, submit them, and create a Pull Request (PR). The body of the PR will be based on the commit message. 16 | 17 | ![demo](assets/demo.gif) 18 | 19 | The `fj` command will also push any new updates if you modify the commits; updated commit messages will also be reflected in PR description. 20 | 21 | ![demo2](assets/demo2.gif) 22 | 23 | ### Update and Rebase after PR being merged 24 | 25 | `fj up` will run `jj git fetch` and `jj rebase -d main` and drop empty commits due to rebase 26 | 27 | ![demo3](assets/demo3.gif) 28 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lazywei/fj/381b08a8fd71be2a2f010146b2ca13049ca69356/assets/demo.gif -------------------------------------------------------------------------------- /assets/demo2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lazywei/fj/381b08a8fd71be2a2f010146b2ca13049ca69356/assets/demo2.gif -------------------------------------------------------------------------------- /assets/demo3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lazywei/fj/381b08a8fd71be2a2f010146b2ca13049ca69356/assets/demo3.gif -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/knadh/koanf/parsers/toml" 12 | "github.com/knadh/koanf/providers/confmap" 13 | "github.com/knadh/koanf/providers/file" 14 | "github.com/knadh/koanf/v2" 15 | "github.com/spf13/cobra" 16 | "golang.org/x/exp/slog" 17 | ) 18 | 19 | var ( 20 | k = *koanf.New(".") 21 | 22 | createDraftPR bool 23 | mainBranch string 24 | ) 25 | 26 | var rootCmd = &cobra.Command{ 27 | Use: "fj", 28 | Short: "A generator for Cobra based Applications", 29 | SilenceUsage: true, 30 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 31 | initConfig() 32 | }, 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | changeIDs, err := getStackChangeIDs() 35 | if err != nil { 36 | return fmt.Errorf("failed to get stack change IDs, err: %v", err) 37 | } 38 | 39 | prStack := []int{} 40 | descriptions := []string{} 41 | 42 | lastBranch := "" 43 | for _, changeID := range changeIDs { 44 | desc, err := getDescription(changeID) 45 | if err != nil { 46 | slog.Error("Failed to get description", "change", changeID, "err", err) 47 | os.Exit(1) 48 | } 49 | 50 | branch, err := getBranch(changeID) 51 | if err != nil { 52 | slog.Error("Failed to get branch", "change", changeID, "err", err) 53 | os.Exit(1) 54 | } 55 | 56 | if branch == "" { 57 | nextPRNum, err := getNextAvailablePRNumber() 58 | if err != nil { 59 | slog.Error("Failed to getNextAvailablePRNumber", "err", err) 60 | os.Exit(1) 61 | } 62 | 63 | branch, err = createBranch(changeID, nextPRNum) 64 | if err != nil { 65 | slog.Error("Failed to create branch", "err", err) 66 | os.Exit(1) 67 | } 68 | } 69 | 70 | if err := exec.Command("jj", "git", "push", "-r", changeID).Run(); err != nil { 71 | slog.Error("Failed to push branch to remote", "err", err) 72 | os.Exit(1) 73 | } 74 | slog.Debug("Branch pushed to remote") 75 | 76 | prNum, err := getPRNumber(branch) 77 | if err != nil { 78 | slog.Error("Failed to check PR exisitence", "err", err) 79 | os.Exit(1) 80 | } 81 | 82 | if prNum == -1 { 83 | slog.Debug("No PR created for branch yet, creating", "branch", branch) 84 | 85 | var baseBranch string 86 | if lastBranch == "" { 87 | baseBranch = mainBranch 88 | } else { 89 | baseBranch = lastBranch 90 | } 91 | 92 | args := []string{"pr", "create", "-H", branch, "-B", baseBranch, "--fill-first"} 93 | if createDraftPR { 94 | args = append(args, "--draft") 95 | } 96 | out, err := run(exec.Command("gh", args...)) 97 | if err != nil { 98 | slog.Error("Failed to create PR", "output", out, "err", err) 99 | os.Exit(1) 100 | } 101 | slog.Debug("PR created", "output", out) 102 | prNum, _ = getPRNumber(branch) 103 | } 104 | prStack = append(prStack, prNum) 105 | descriptions = append(descriptions, desc) 106 | 107 | lastBranch = branch 108 | } 109 | 110 | for i, prNum := range prStack { 111 | desc := descriptions[i] 112 | 113 | var prInfo string 114 | if len(prStack) > 1 { 115 | prInfo = "\n---" 116 | for j := len(prStack) - 1; j >= 0; j-- { 117 | if i == j { 118 | prInfo += fmt.Sprintf("\n* **->** #%d", prStack[j]) 119 | } else { 120 | prInfo += fmt.Sprintf("\n* #%d", prStack[j]) 121 | } 122 | } 123 | } else { 124 | prInfo = "" 125 | } 126 | 127 | if out, err := run(exec.Command("gh", "pr", "edit", fmt.Sprint(prNum), "-b", desc+"\n"+prInfo)); err == nil { 128 | fmt.Println("Successfully updated PR:", out) 129 | } 130 | } 131 | 132 | return nil 133 | }, 134 | } 135 | 136 | // Execute executes the root command. 137 | func Execute() error { 138 | return rootCmd.Execute() 139 | } 140 | 141 | func initConfig() { 142 | // Load default values using the confmap provider. 143 | // We provide a flat map with the "." delimiter. 144 | // A nested map can be loaded by setting the delimiter to an empty string "". 145 | k.Load(confmap.Provider(map[string]interface{}{ 146 | "mainBranch": "main", 147 | "branchPrefix": "username/pr-", 148 | "draft": true, 149 | }, "."), nil) 150 | 151 | projectRoot, err := run(exec.Command("git", "rev-parse", "--show-toplevel")) 152 | if err != nil { 153 | slog.Error("Not in a git repo", "err", err) 154 | os.Exit(1) 155 | } 156 | configPath := filepath.Join(projectRoot, ".fj.toml") 157 | 158 | // Load TOML config on top of the default values. 159 | if err := k.Load(file.Provider(configPath), toml.Parser()); err != nil { 160 | b, err := k.Marshal(toml.Parser()) 161 | if err != nil { 162 | slog.Error("Failed to marshal default config", "err", err) 163 | os.Exit(1) 164 | } 165 | if err := os.WriteFile(configPath, b, 0o644); err != nil { 166 | slog.Error("Failed to write default config", "configPath", configPath, "err", err) 167 | os.Exit(1) 168 | } 169 | slog.Info("Initialized default config, please re-run the command", "configPath", configPath) 170 | os.Exit(0) 171 | } 172 | 173 | mainBranch = k.MustString("mainBranch") 174 | if k.Bool("draft") { 175 | createDraftPR = true 176 | } 177 | } 178 | 179 | func getStackChangeIDs() ([]string, error) { 180 | return getChangeIDs(fmt.Sprintf("%s..@-", mainBranch)) 181 | } 182 | 183 | func getDescription(changeID string) (string, error) { 184 | return run(exec.Command( 185 | "jj", "log", "--no-graph", 186 | "-T", `description`, 187 | "-r", changeID, 188 | )) 189 | } 190 | 191 | func getBranch(changeID string) (string, error) { 192 | out, err := run(exec.Command( 193 | "jj", "branch", "list", 194 | "-r", changeID, 195 | )) 196 | if err != nil { 197 | return out, err 198 | } 199 | return strings.Split(out, ":")[0], nil 200 | } 201 | 202 | func getNextAvailablePRNumber() (int, error) { 203 | cmd := "gh pr list -L 1 --state all --json number | jq '.[0].number'" 204 | out, err := run(exec.Command("bash", "-c", cmd)) 205 | if err != nil { 206 | return -1, fmt.Errorf("output: %s, err: %v", out, err) 207 | } 208 | if out == "null" { 209 | // No PRs yet 210 | return 1, nil 211 | } 212 | curPRNum, err := strconv.Atoi(out) 213 | if err != nil { 214 | return -1, err 215 | } 216 | 217 | return curPRNum + 1, nil 218 | } 219 | 220 | func createBranch(changeID string, nextPRNum int) (string, error) { 221 | branchName := fmt.Sprintf("%s%d", k.MustString("branchPrefix"), nextPRNum) 222 | 223 | out, err := run(exec.Command("jj", "branch", "create", "-r", changeID, branchName)) 224 | if err != nil { 225 | return "", fmt.Errorf("output: %s, err: %v", out, err) 226 | } 227 | 228 | return branchName, nil 229 | } 230 | 231 | func getPRNumber(branch string) (int, error) { 232 | cmd := fmt.Sprintf("gh pr list -L 1 --state all --json number --head %s | jq '.[0].number'", branch) 233 | out, err := run(exec.Command("bash", "-c", cmd)) 234 | if err != nil { 235 | return -1, err 236 | } 237 | 238 | number, err := strconv.Atoi(out) 239 | if err != nil { 240 | return -1, nil 241 | } 242 | 243 | return number, nil 244 | } 245 | -------------------------------------------------------------------------------- /cmd/script_utils.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | ) 9 | 10 | func run(c *exec.Cmd) (string, error) { 11 | out, err := c.CombinedOutput() 12 | ret := strings.TrimRight(string(out), "\n") 13 | return ret, err 14 | } 15 | 16 | func runStdout(c *exec.Cmd) error { 17 | c.Stdout = os.Stdout 18 | return c.Run() 19 | } 20 | 21 | func getChangeIDs(revset string) ([]string, error) { 22 | out, err := run(exec.Command( 23 | "jj", "log", "--no-graph", "--reversed", 24 | "-r", revset, 25 | "-T", `change_id ++ "\n"`, 26 | )) 27 | if err != nil { 28 | return []string{}, fmt.Errorf("jj out: %s\nerr: %s", out, err) 29 | } 30 | 31 | if len(out) == 0 { 32 | return []string{}, nil 33 | } 34 | 35 | changeIDs := strings.Split(out, "\n") 36 | return changeIDs, nil 37 | } 38 | -------------------------------------------------------------------------------- /cmd/update.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func init() { 14 | rootCmd.AddCommand(updateCmd) 15 | } 16 | 17 | var updateCmd = &cobra.Command{ 18 | Use: "up", 19 | Short: "jj git fetch + rebase -d master/main + drop empty commits", 20 | RunE: func(c *cobra.Command, args []string) error { 21 | out, err := run(exec.Command("jj", "git", "fetch")) 22 | if err != nil { 23 | return fmt.Errorf("failed to fetch from git, out: %s, err: %v", out, err) 24 | } 25 | 26 | out, err = run(exec.Command("jj", "rebase", "-d", mainBranch)) 27 | if err != nil { 28 | return fmt.Errorf("failed to rebase to %s, out: %s, err: %v", 29 | mainBranch, out, err) 30 | } 31 | 32 | emptyChangeIDs, err := getChangeIDs(fmt.Sprintf("(%s..@-) & empty()", mainBranch)) 33 | if err != nil { 34 | return fmt.Errorf("failed to get empty change IDs, err: %v", err) 35 | } 36 | if err := runStdout(exec.Command("jj", "log", "-r", fmt.Sprintf("%s-..@", mainBranch))); err != nil { 37 | return err 38 | } 39 | 40 | for _, changeID := range emptyChangeIDs { 41 | fmt.Printf("Abandoning change '%s'? (y/n) ", changeID[:5]) 42 | 43 | reader := bufio.NewReader(os.Stdin) 44 | text, err := reader.ReadString('\n') 45 | if err != nil { 46 | return fmt.Errorf("failed to read from stdin, err: %v", err) 47 | } 48 | text = strings.ToLower(strings.Trim(text, "\n")) 49 | 50 | if text == "y" || text == "yes" { 51 | run(exec.Command("jj", "abandon", "-r", changeID)) 52 | } else { 53 | fmt.Println("Abort") 54 | return nil 55 | } 56 | } 57 | 58 | return nil 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lazywei/fj 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/knadh/koanf/parsers/toml v0.1.0 7 | github.com/knadh/koanf/providers/confmap v0.1.0 8 | github.com/knadh/koanf/providers/file v0.1.0 9 | github.com/knadh/koanf/v2 v2.0.1 10 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 11 | ) 12 | 13 | require ( 14 | github.com/fsnotify/fsnotify v1.6.0 // indirect 15 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 16 | github.com/knadh/koanf/maps v0.1.1 // indirect 17 | github.com/mitchellh/copystructure v1.2.0 // indirect 18 | github.com/mitchellh/mapstructure v1.5.0 // indirect 19 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 20 | github.com/pelletier/go-toml v1.9.5 // indirect 21 | github.com/spf13/cobra v1.7.0 // indirect 22 | github.com/spf13/pflag v1.0.5 // indirect 23 | golang.org/x/sys v0.12.0 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 4 | github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 5 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 6 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 7 | github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= 8 | github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= 9 | github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI= 10 | github.com/knadh/koanf/parsers/toml v0.1.0/go.mod h1:yUprhq6eo3GbyVXFFMdbfZSo928ksS+uo0FFqNMnO18= 11 | github.com/knadh/koanf/providers/confmap v0.1.0 h1:gOkxhHkemwG4LezxxN8DMOFopOPghxRVp7JbIvdvqzU= 12 | github.com/knadh/koanf/providers/confmap v0.1.0/go.mod h1:2uLhxQzJnyHKfxG927awZC7+fyHFdQkd697K4MdLnIU= 13 | github.com/knadh/koanf/providers/file v0.1.0 h1:fs6U7nrV58d3CFAFh8VTde8TM262ObYf3ODrc//Lp+c= 14 | github.com/knadh/koanf/providers/file v0.1.0/go.mod h1:rjJ/nHQl64iYCtAW2QQnF0eSmDEX/YZ/eNFj5yR6BvA= 15 | github.com/knadh/koanf/v2 v2.0.1 h1:1dYGITt1I23x8cfx8ZnldtezdyaZtfAuRtIFOiRzK7g= 16 | github.com/knadh/koanf/v2 v2.0.1/go.mod h1:ZeiIlIDXTE7w1lMT6UVcNiRAS2/rCeLn/GdLNvY1Dus= 17 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 18 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 19 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 20 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 21 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 22 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 23 | github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= 24 | github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= 25 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 26 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 27 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 28 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 29 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 30 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 31 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 32 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 33 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 34 | golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 35 | golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= 36 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 37 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 38 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 39 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 40 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/lazywei/fj/cmd" 7 | ) 8 | 9 | func main() { 10 | if err := cmd.Execute(); err != nil { 11 | os.Exit(1) 12 | } 13 | } 14 | --------------------------------------------------------------------------------