├── assets └── screen.gif ├── test └── fixture │ └── gitconfig ├── .github ├── dependabot.yml ├── FUNDING.yml └── workflows │ └── release.yml ├── text.go ├── CONTRIBUTING.md ├── test_helper.go ├── .gitignore ├── Makefile ├── docker ├── build.dockerfile └── test.dockerfile ├── LICENSE ├── main.go ├── .goreleaser.yml ├── command_fetch.go ├── command_new.go ├── command_list_test.go ├── command_update.go ├── .all-contributorsrc ├── go.mod ├── command_timeline.go ├── command_remove.go ├── command_list.go ├── command_doctor.go ├── ghq.go ├── README.md ├── go.sum └── git.go /assets/screen.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uetchy/gst/HEAD/assets/screen.gif -------------------------------------------------------------------------------- /test/fixture/gitconfig: -------------------------------------------------------------------------------- 1 | [user] 2 | name = John Doe 3 | email = john@example.com 4 | [ghq] 5 | root = /go/src -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "20:00" 8 | timezone: Asia/Tokyo 9 | open-pull-requests-limit: 10 10 | -------------------------------------------------------------------------------- /text.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | ct "github.com/daviddengcn/go-colortext" 7 | ) 8 | 9 | func printWithColor(str string, color ct.Color) { 10 | ct.ChangeColor(color, false, ct.None, false) 11 | fmt.Print(str) 12 | ct.ResetColor() 13 | } 14 | 15 | func printlnWithColor(str string, color ct.Color) { 16 | printWithColor(str+"\n", color) 17 | } 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | ## Development Guide 4 | 5 | ```bash 6 | git clone https://github.com/uetchy/gst.git && cd gst 7 | go build 8 | ``` 9 | 10 | ## Release Guide (Maintainers only) 11 | 12 | ```bash 13 | VERSION=vX.X.X 14 | npx mdmod README.md --define.version $VERSION 15 | git add . 16 | git commit -m "chore: release ${VERSION}" 17 | git tag -a "$VERSION" $VERSION 18 | git push 19 | ``` 20 | -------------------------------------------------------------------------------- /test_helper.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | ) 8 | 9 | func captureStdout(f func()) string { 10 | r, w, err := os.Pipe() 11 | if err != nil { 12 | panic(err) 13 | } 14 | 15 | stdout := os.Stdout 16 | os.Stdout = w 17 | 18 | f() 19 | 20 | os.Stdout = stdout 21 | w.Close() 22 | 23 | var buf bytes.Buffer 24 | io.Copy(&buf, r) 25 | 26 | return buf.String() 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | /dist 3 | /pkg 4 | 5 | # dep 6 | /vendor 7 | /tags 8 | 9 | # Created by https://www.gitignore.io/api/go 10 | 11 | ### Go ### 12 | # Binaries for programs and plugins 13 | *.exe 14 | *.exe~ 15 | *.dll 16 | *.so 17 | *.dylib 18 | 19 | # Test binary, build with `go test -c` 20 | *.test 21 | !Dockerfile.test 22 | 23 | # Output of the go coverage tool, specifically when used with LiteIDE 24 | *.out 25 | 26 | # End of https://www.gitignore.io/api/go 27 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | docker build -f docker/build.dockerfile -t uetchy/gst . 3 | 4 | readme: 5 | npx mdmod README.md --define.version $$(git describe --tags --match 'v*' --abbrev=0) 6 | 7 | run: build 8 | docker run --rm -v $$(ghq root):/ghq -it uetchy/gst --help 9 | 10 | push: build 11 | docker push uetchy/gst 12 | 13 | build-test: 14 | docker build -f docker/test.dockerfile -t uetchy/gst:test . 15 | 16 | test: build-test 17 | docker run --rm -it uetchy/gst:test go test github.com/uetchy/gst 18 | 19 | -------------------------------------------------------------------------------- /docker/build.dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.14 as BUILD 2 | 3 | ENV GO111MODULE on 4 | 5 | # install ghq 6 | RUN go get github.com/x-motemen/ghq 7 | 8 | # install deps 9 | WORKDIR /go/src/github.com/uetchy/gst 10 | 11 | # build gst 12 | COPY *.go go.mod go.sum ./ 13 | RUN CGO_ENABLED=0 GOOS=linux go install -ldflags="-w -s" -v github.com/uetchy/gst 14 | 15 | # copy binaries from build step 16 | FROM alpine 17 | ENV GHQ_ROOT /ghq 18 | ENV PATH /go/bin:$PATH 19 | RUN apk add git 20 | COPY --from=BUILD /go/bin/ghq /go/bin/ghq 21 | COPY --from=BUILD /go/bin/gst /go/bin/gst 22 | ENTRYPOINT ["gst"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Yasuaki Uechi 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /docker/test.dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.14 2 | 3 | ENV GO111MODULE on 4 | 5 | # install ghq 6 | RUN go get github.com/x-motemen/ghq 7 | COPY test/fixture/gitconfig /root/.gitconfig 8 | 9 | # deploy fixtures for test 10 | RUN ghq get github/gitignore 11 | WORKDIR /go/src/github.com/github/gitignore 12 | RUN touch newfile 13 | RUN rm Go.gitignore 14 | RUN echo "*" >Node.gitignore 15 | RUN echo "*" >committedfile 16 | RUN git add committedfile 17 | RUN git commit -m 'Add new file' 18 | 19 | WORKDIR /go/src/github.com/uetchy/gst 20 | 21 | # build gst 22 | COPY *.go go.mod go.sum ./ 23 | RUN CGO_ENABLED=0 GOOS=linux go install -ldflags="-w -s" -v github.com/uetchy/gst 24 | RUN gst 25 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [uetchy] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/urfave/cli" 7 | ) 8 | 9 | // Version of this program 10 | var Version = "HEAD" 11 | 12 | // Commands are list of available commands 13 | var Commands = []cli.Command{ 14 | commandList, 15 | commandNew, 16 | commandRemove, 17 | commandDoctor, 18 | commandUpdate, 19 | commandFetch, 20 | commandTimeline, 21 | } 22 | 23 | func main() { 24 | app := cli.NewApp() 25 | app.Name = "gst" 26 | app.Version = Version 27 | app.Usage = "gst" 28 | app.Author = "Yasuaki Uechi" 29 | app.Email = "y@uechi.io" 30 | app.Commands = Commands 31 | 32 | // Declare default action 33 | app.HideHelp = true 34 | app.Flags = flagsOfList 35 | app.Action = doList 36 | 37 | app.Run(os.Args) 38 | } 39 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example goreleaser.yaml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | before: 4 | hooks: 5 | # You may remove this if you don't use go modules. 6 | - go mod download 7 | # you may remove this if you don't need go generate 8 | # - go generate ./... 9 | builds: 10 | - env: 11 | - CGO_ENABLED=0 12 | goos: 13 | - linux 14 | - darwin 15 | - windows 16 | goarch: 17 | - amd64 18 | - arm64 19 | checksum: 20 | name_template: "checksums.txt" 21 | snapshot: 22 | name_template: "{{ .Tag }}-next" 23 | changelog: 24 | sort: asc 25 | filters: 26 | exclude: 27 | - "^docs:" 28 | - "^test:" 29 | - "^chore:" 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: "16" 20 | - name: Set up Go 21 | uses: actions/setup-go@v2 22 | with: 23 | go-version: "1.16" 24 | - name: Run GoReleaser 25 | uses: goreleaser/goreleaser-action@v2 26 | with: 27 | version: latest 28 | args: release --rm-dist 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /command_fetch.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/urfave/cli" 7 | ct "github.com/daviddengcn/go-colortext" 8 | ) 9 | 10 | var flagsOfFetch = []cli.Flag{ 11 | cli.BoolFlag{ 12 | Name: "short, s", 13 | Usage: "shorten result for pipeline processing", 14 | }, 15 | } 16 | 17 | var commandFetch = cli.Command{ 18 | Name: "fetch", 19 | Action: doFetch, 20 | Flags: flagsOfFetch, 21 | } 22 | 23 | func doFetch(c *cli.Context) error { 24 | ghqPath := verifyGhqPath() 25 | repos := searchForRepos(ghqPath) 26 | 27 | // Listing repos 28 | for repo := range repos { 29 | printlnWithColor(repo.Path, ct.Cyan) 30 | out, err := GitFetch(repo.Path) 31 | if err != nil { 32 | fmt.Println(err) 33 | continue 34 | } 35 | if out != "" { 36 | fmt.Println(err) 37 | } 38 | } 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /command_new.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | 9 | "github.com/urfave/cli" 10 | ) 11 | 12 | var commandNew = cli.Command{ 13 | Name: "new", 14 | Action: doNew, 15 | } 16 | 17 | func check(e error) { 18 | if e != nil { 19 | fmt.Println(e) 20 | os.Exit(1) 21 | } 22 | } 23 | 24 | func doNew(c *cli.Context) error { 25 | name := c.Args().Get(0) 26 | target := compileTargetPath(name) 27 | 28 | err := exec.Command("mkdir", "-p", target).Run() 29 | check(err) 30 | 31 | err = os.Chdir(target) 32 | check(err) 33 | 34 | err = exec.Command("git", "init").Run() 35 | check(err) 36 | 37 | f, err := os.Create(filepath.Join(target, "README.md")) 38 | check(err) 39 | defer f.Close() 40 | _, err = f.WriteString("# " + name + "\n") 41 | f.Sync() 42 | 43 | fmt.Println(target) 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /command_list_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/andreyvit/diff" 8 | "github.com/urfave/cli" 9 | ) 10 | 11 | const expected = ` 12 | uncommitted changes 13 | D Go.gitignore 14 | M Node.gitignore 15 | ?? newfile 16 | unpushed commits 17 | ` 18 | 19 | func TestCommandList(t *testing.T) { 20 | app := cli.NewApp() 21 | app.Flags = flagsOfList 22 | app.Action = doList 23 | 24 | var err error 25 | 26 | out := captureStdout(func() { 27 | err = app.Run([]string{"gst"}) 28 | }) 29 | 30 | if err != nil { 31 | t.Errorf("Unexpected exit code: %s", err) 32 | } 33 | 34 | line := strings.Split(strings.TrimSpace(out), "\n") 35 | truncated := strings.Join(line[1:len(line)-1], "\n") 36 | if a, e := strings.TrimSpace(truncated), strings.TrimSpace(expected); a != e { 37 | t.Errorf("Unexpected output\n%v", diff.LineDiff(e, a)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /command_update.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/urfave/cli" 8 | ct "github.com/daviddengcn/go-colortext" 9 | ) 10 | 11 | var flagsOfUpdate = []cli.Flag{ 12 | cli.BoolFlag{ 13 | Name: "short, s", 14 | Usage: "shorten result for pipeline processing", 15 | }, 16 | } 17 | 18 | var commandUpdate = cli.Command{ 19 | Name: "update", 20 | Action: doUpdate, 21 | Flags: flagsOfUpdate, 22 | } 23 | 24 | func doUpdate(c *cli.Context) error { 25 | ghqPath := verifyGhqPath() 26 | repos := searchForRepos(ghqPath) 27 | 28 | // Listing repos 29 | for repo := range repos { 30 | printlnWithColor(repo.Path, ct.Cyan) 31 | out, err := GitPull(repo.Path) 32 | if err != nil { 33 | fmt.Println(err) 34 | continue 35 | } 36 | if strings.Contains(out, "Already up-to-date") != true { 37 | fmt.Println(out) 38 | } 39 | } 40 | 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "gst", 3 | "projectOwner": "uetchy", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "angular", 12 | "contributors": [ 13 | { 14 | "login": "uetchy", 15 | "name": "Yasuaki Uechi", 16 | "avatar_url": "https://avatars0.githubusercontent.com/u/431808?v=4", 17 | "profile": "https://uechi.io", 18 | "contributions": [ 19 | "code", 20 | "doc" 21 | ] 22 | }, 23 | { 24 | "login": "sinshutu", 25 | "name": "NaotoSuzuki", 26 | "avatar_url": "https://avatars0.githubusercontent.com/u/7629220?v=4", 27 | "profile": "https://github.com/sinshutu", 28 | "contributions": [ 29 | "code" 30 | ] 31 | } 32 | ], 33 | "contributorsPerLine": 7 34 | } 35 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/uetchy/gst 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/Songmu/prompter v0.4.0 7 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 8 | github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect 9 | github.com/daviddengcn/go-colortext v0.0.0-20180409174941-186a3d44e920 10 | github.com/dustin/go-humanize v1.0.0 11 | github.com/golangplus/bytes v0.0.0-20160111154220-45c989fe5450 // indirect 12 | github.com/golangplus/fmt v0.0.0-20150411045040-2a5d6d7d2995 // indirect 13 | github.com/golangplus/testing v0.0.0-20180327235837-af21d9c3145e // indirect 14 | github.com/mattn/go-isatty v0.0.11 // indirect 15 | github.com/motemen/go-gitconfig v0.0.0-20160409144229-d53da5028b75 16 | github.com/sergi/go-diff v1.0.0 // indirect 17 | github.com/stretchr/testify v1.3.0 // indirect 18 | github.com/urfave/cli v1.22.4 19 | golang.org/x/crypto v0.0.0-20191219195013-becbf705a915 // indirect 20 | golang.org/x/sys v0.0.0-20191220220014-0732a990476f // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /command_timeline.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sort" 5 | "time" 6 | 7 | "github.com/urfave/cli" 8 | ct "github.com/daviddengcn/go-colortext" 9 | "github.com/dustin/go-humanize" 10 | ) 11 | 12 | var flagsOfTimeline = []cli.Flag{ 13 | cli.BoolFlag{ 14 | Name: "short, s", 15 | Usage: "shorten result for pipeline processing", 16 | }, 17 | } 18 | 19 | var commandTimeline = cli.Command{ 20 | Name: "timeline", 21 | Action: doTimeline, 22 | Flags: flagsOfTimeline, 23 | } 24 | 25 | func doTimeline(c *cli.Context) error { 26 | ghqPath := verifyGhqPath() 27 | reposChannel := searchForRepos(ghqPath) 28 | 29 | // Sort by time 30 | repos := []Repository{} 31 | for repo := range reposChannel { 32 | repos = append(repos, repo) 33 | } 34 | sort.Sort(RepositoriesByModTime{repos}) 35 | 36 | // Listing repos 37 | for _, repo := range repos { 38 | duration := time.Now().Sub(repo.ModTime).Hours() 39 | var timeColor ct.Color 40 | if duration > 4320 { // 6 months 41 | timeColor = ct.Red 42 | } else if duration > 2160 { // 3 months 43 | timeColor = ct.Yellow 44 | } else if duration > 720 { // 1 month 45 | timeColor = ct.Green 46 | } else if duration > 504 { // 3 weeks 47 | timeColor = ct.Blue 48 | } else if duration > 168 { // 1 week 49 | timeColor = ct.Magenta 50 | } else { 51 | timeColor = ct.White 52 | } 53 | printlnWithColor(repo.Path+" ("+humanize.Time(repo.ModTime)+")", timeColor) 54 | } 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /command_remove.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/Songmu/prompter" 10 | "github.com/urfave/cli" 11 | ) 12 | 13 | var commandRemove = cli.Command{ 14 | Name: "remove", 15 | Action: doRemove, 16 | Flags: []cli.Flag{ 17 | cli.BoolFlag{ 18 | Name: "f, force", 19 | Usage: "Remove without prompt", 20 | }, 21 | }, 22 | } 23 | 24 | // IsEmpty checks if directory is empty 25 | func IsEmpty(name string) (bool, error) { 26 | f, err := os.Open(name) 27 | if err != nil { 28 | return false, err 29 | } 30 | defer f.Close() 31 | 32 | _, err = f.Readdir(1) 33 | if err == io.EOF { 34 | return true, nil 35 | } 36 | 37 | return false, err // Either not empty or error, suits both cases 38 | } 39 | 40 | func doRemove(c *cli.Context) error { 41 | forceRemove := c.Bool("force") 42 | 43 | for _, arg := range c.Args() { 44 | target := compileTargetPath(arg) 45 | 46 | if _, err := os.Stat(target); err == nil { 47 | if !forceRemove && !prompter.YN("Remove? "+target, true) { 48 | os.Exit(0) 49 | } 50 | 51 | // Remove specified directory 52 | err := os.RemoveAll(target) 53 | if err == nil { 54 | fmt.Println("Removed: " + target) 55 | } else { 56 | fmt.Println(err) 57 | } 58 | 59 | // Remove parent dirs if empty 60 | ghqPath, _ := getGhqPath() 61 | target = filepath.Dir(target) 62 | for target != ghqPath { 63 | if e, _ := IsEmpty(target); !e { 64 | break 65 | } 66 | err := os.RemoveAll(target) 67 | if err == nil { 68 | fmt.Println("Removed: " + target) 69 | } else { 70 | fmt.Println(err) 71 | break 72 | } 73 | target = filepath.Dir(target) 74 | } 75 | } else { 76 | fmt.Println("Doesn't exist: " + target) 77 | } 78 | } 79 | 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /command_list.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | 8 | ct "github.com/daviddengcn/go-colortext" 9 | "github.com/dustin/go-humanize" 10 | "github.com/urfave/cli" 11 | ) 12 | 13 | var flagsOfList = []cli.Flag{ 14 | cli.BoolFlag{ 15 | Name: "short, s", 16 | Usage: "only prints path strings", 17 | }, 18 | } 19 | 20 | var commandList = cli.Command{ 21 | Name: "list", 22 | Action: doList, 23 | Flags: flagsOfList, 24 | } 25 | 26 | func doList(c *cli.Context) error { 27 | if c.Args().Present() { 28 | cli.ShowAppHelpAndExit(c, 0) 29 | } 30 | 31 | ghqPath := verifyGhqPath() 32 | reposChannel := searchForRepos(ghqPath) 33 | 34 | shortExpression := c.Bool("short") 35 | 36 | // Sort by time 37 | repos := []Repository{} 38 | for repo := range reposChannel { 39 | repos = append(repos, repo) 40 | } 41 | sort.Sort(RepositoriesByModTime{repos}) 42 | 43 | // Listing repos 44 | for _, repo := range repos { 45 | uncommitedChanges, ccErr := GitStatus(repo.Path) 46 | unpushedCommits, pcErr := GitLog(repo.Path) 47 | if ccErr != nil && pcErr != nil { 48 | continue 49 | } 50 | 51 | if shortExpression { 52 | fmt.Println(repo.Path) 53 | continue 54 | } 55 | 56 | printlnWithColor(repo.Path+" ("+humanize.Time(repo.ModTime)+")", ct.Cyan) 57 | 58 | // print uncommited changes 59 | if ccErr == nil { 60 | printlnWithColor("uncommitted changes", ct.Magenta) 61 | for _, changes := range uncommitedChanges { 62 | staged := changes[:1] 63 | unstaged := changes[1:2] 64 | filename := changes[3:] 65 | 66 | if staged == "?" { 67 | printWithColor(staged, ct.Red) 68 | } else { 69 | printWithColor(staged, ct.Green) 70 | } 71 | printWithColor(unstaged, ct.Red) 72 | fmt.Println("", filename) 73 | } 74 | } 75 | 76 | // print unpushed commits 77 | if pcErr == nil { 78 | printlnWithColor("unpushed commits", ct.Magenta) 79 | for _, commit := range unpushedCommits { 80 | line := strings.Split(commit, " ") 81 | printWithColor(line[0], ct.Yellow) 82 | fmt.Println(" " + strings.Join(line[1:], " ")) 83 | } 84 | } 85 | 86 | fmt.Println() 87 | } 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /command_doctor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/Songmu/prompter" 11 | "github.com/urfave/cli" 12 | ) 13 | 14 | var commandDoctor = cli.Command{ 15 | Name: "doctor", 16 | Action: doDoctor, 17 | Flags: []cli.Flag{ 18 | cli.BoolFlag{ 19 | Name: "fix", 20 | Usage: "automatically fix issues", 21 | }, 22 | }, 23 | } 24 | 25 | func doDoctor(c *cli.Context) error { 26 | fixupIssues := c.Bool("fix") 27 | ghqPath := verifyGhqPath() 28 | reposChannel := searchForRepos(ghqPath) 29 | 30 | // Listing repos 31 | for repo := range reposChannel { 32 | remoteOriginURL, _ := GitConfigGet(repo.Path, "remote.origin.url") 33 | trimmedRemote := compileTargetPathFromURL(remoteOriginURL) 34 | trimmedLocal := strings.TrimPrefix(repo.Path, ghqPath+"/") 35 | 36 | if remoteOriginURL == "" { 37 | continue 38 | } 39 | 40 | if trimmedRemote != trimmedLocal && !strings.Contains(trimmedLocal, "golang.org/x/") { 41 | fmt.Println("===> 'remote.origin.url' has been changed") 42 | fmt.Println("===> local ", trimmedLocal) 43 | fmt.Println("===> remote", trimmedRemote) 44 | 45 | if fixupIssues { 46 | fmt.Println("===> Choose the right location for" + trimmedLocal) 47 | fmt.Println("[1] " + trimmedLocal) 48 | fmt.Println("[2] " + trimmedRemote) 49 | choice := prompter.Choose("===>", []string{"1", "2"}, "1") 50 | if choice == "1" { 51 | // Change remote.origin 52 | slp := strings.Split(trimmedLocal, "/") 53 | remotePathFromLocal := fmt.Sprintf("git@%s:%s/%s.git", slp[0], slp[1], slp[2]) 54 | err := GitRemoteSetURL(repo.Path, "origin", remotePathFromLocal) 55 | if err != nil { 56 | fmt.Println("===> Failed because of", err) 57 | continue 58 | } 59 | fmt.Println("===> Change remote.origin.url to", remotePathFromLocal) 60 | } else { 61 | // Move directory 62 | localPathFromRemote := filepath.Join(ghqPath, trimmedRemote) 63 | 64 | fmt.Println(localPathFromRemote) 65 | if _, err := os.Stat(localPathFromRemote); os.IsExist(err) { 66 | fmt.Println("===> Failed to move repository because", localPathFromRemote, "already exist") 67 | continue 68 | } 69 | 70 | os.MkdirAll(filepath.Dir(localPathFromRemote), fs.ModeDir) 71 | 72 | if err := os.Rename(repo.Path, localPathFromRemote); err != nil { 73 | fmt.Println("===> Failed to move repository because of", err) 74 | continue 75 | } 76 | 77 | fmt.Println("===> Moved repository from", repo.Path, "to", localPathFromRemote) 78 | } 79 | } 80 | } 81 | } 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /ghq.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "regexp" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | // Repository represents git repository 15 | type Repository struct { 16 | Type string 17 | Path string 18 | ModTime time.Time 19 | } 20 | 21 | // Repositories contains array of Repository 22 | type Repositories []Repository 23 | 24 | // Len return number of repositories 25 | func (r Repositories) Len() int { 26 | return len(r) 27 | } 28 | 29 | // Swap repository 30 | func (r Repositories) Swap(i, j int) { 31 | r[i], r[j] = r[j], r[i] 32 | } 33 | 34 | // RepositoriesByModTime is wrapper of sort algorithm for order by mod time 35 | type RepositoriesByModTime struct { 36 | Repositories 37 | } 38 | 39 | // Less sort array by mod time 40 | func (bmt RepositoriesByModTime) Less(i, j int) bool { 41 | return bmt.Repositories[i].ModTime.Before(bmt.Repositories[j].ModTime) 42 | } 43 | 44 | func verifyGhqPath() string { 45 | ghqPath, err := getGhqPath() 46 | if err != nil { 47 | fmt.Println("You must setup ghq first") 48 | os.Exit(1) 49 | } 50 | return ghqPath 51 | } 52 | 53 | func getGhqPath() (string, error) { 54 | out, err := exec.Command("ghq", "root").Output() 55 | if err != nil { 56 | return "", err 57 | } 58 | return string(out)[:len(out)-1], nil 59 | } 60 | 61 | func searchForRepos(rootPath string) <-chan Repository { 62 | repos := make(chan Repository) 63 | 64 | go func() { 65 | filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error { 66 | // skip file 67 | if !info.IsDir() { 68 | return nil 69 | } 70 | 71 | // skip directories which is not a repository 72 | if _, err := os.Stat(filepath.Join(path, ".git")); err != nil { 73 | return nil 74 | } 75 | 76 | repository := Repository{ 77 | Type: "git", 78 | Path: path, 79 | ModTime: info.ModTime(), 80 | } 81 | repos <- repository 82 | 83 | return filepath.SkipDir 84 | }) 85 | close(repos) 86 | }() 87 | 88 | return repos 89 | } 90 | 91 | var hasSchemePattern = regexp.MustCompile("^[^:]+://") 92 | var scpLikeURLPattern = regexp.MustCompile("^([^@]+@)?([^:]+):/?(.+)$") 93 | 94 | func formatURL(ref string) (*url.URL, error) { 95 | if !hasSchemePattern.MatchString(ref) && scpLikeURLPattern.MatchString(ref) { 96 | matched := scpLikeURLPattern.FindStringSubmatch(ref) 97 | user := matched[1] 98 | host := matched[2] 99 | path := matched[3] 100 | 101 | ref = fmt.Sprintf("ssh://%s%s/%s", user, host, path) 102 | } 103 | 104 | url, err := url.Parse(ref) 105 | if err != nil { 106 | return url, err 107 | } 108 | 109 | if !url.IsAbs() { 110 | if !strings.Contains(url.Path, "/") { 111 | url.Path = url.Path + "/" + url.Path 112 | } 113 | url.Scheme = "https" 114 | url.Host = "github.com" 115 | if url.Path[0] != '/' { 116 | url.Path = "/" + url.Path 117 | } 118 | } 119 | 120 | return url, nil 121 | } 122 | 123 | func compileTargetPathFromURL(query string) string { 124 | source, _ := formatURL(query) 125 | encodedPath := strings.TrimSuffix(source.Path, ".git") 126 | ghqPath := filepath.Join(source.Host, encodedPath) 127 | return ghqPath 128 | } 129 | 130 | func compileTargetPath(query string) string { 131 | ghqPath, err := getGhqPath() 132 | if err != nil { 133 | fmt.Println("You must setup 'ghq' command") 134 | os.Exit(1) 135 | } 136 | 137 | re, _ := regexp.Compile("^(?:(?:(.+?)/)?(.+?)/)?(.+)$") 138 | res := re.FindStringSubmatch(query) 139 | 140 | targetHost := res[1] 141 | targetUser := res[2] 142 | targetPath := res[3] 143 | 144 | if res[1] == "" { 145 | targetHost = "github.com" 146 | } 147 | 148 | if res[2] == "" { 149 | targetUser, err = GitConfigGet("global", "github.user") 150 | if err != nil { 151 | fmt.Println("You must set github.user first") 152 | fmt.Println("> git config --global github.user ") 153 | os.Exit(1) 154 | } 155 | } 156 | 157 | return filepath.Join(ghqPath, targetHost, targetUser, targetPath) 158 | } 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

gst 👻

3 |
4 | 5 |

6 | 7 | [![Actions Status: release](https://github.com/uetchy/gst/workflows/goreleaser/badge.svg)](https://github.com/uetchy/gst/actions?query=goreleaser) 8 | [![sake.sh badge](https://sake.sh/uetchy/badge.svg)](https://sake.sh/uetchy) 9 | 10 | **gst** is a simple toolbox that offers additional commands (`list`, `new`, `rm`, `doctor`, `update`, `fetch`) for [ghq](https://github.com/x-motemen/ghq). 11 | 12 | See [Quick Install](https://github.com/uetchy/gst#quick-install) for the installation guide. 13 | 14 | ## Usage 15 | 16 | ### `gst list` or `gst` 17 | 18 | List **uncommitted changes** and **unpushed commits** within all repositories. 19 | 20 | ```bash 21 | $ gst 22 | /Users/uetchy/Repos/src/github.com/uetchy/gst (11 minutes ago) 23 | uncommitted changes 24 | M .travis.yml 25 | M README.md 26 | 27 | /Users/uetchy/Repos/src/github.com/uetchy/qiita-takeout (9 hours ago) 28 | unpushed commits 29 | 409849d returns Promise.reject 30 | ``` 31 | 32 | with `--short` or `-s` option: 33 | 34 | ```bash 35 | $ gst --short 36 | /Users/uetchy/Repos/src/github.com/uetchy/ferret 37 | /Users/uetchy/Repos/src/github.com/uetchy/gst 38 | ``` 39 | 40 | ### new 41 | 42 | Create a new git repository. 43 | 44 | Before start using `new` and `rm` command, You **must** set `github.user` in gitconfig to your GitHub username: `git config --global github.user `. 45 | 46 | ```bash 47 | $ gst new epic-project 48 | /Users/uetchy/Repos/src/github.com/uetchy/epic-project 49 | $ gst new epic-team/epic-project 50 | /Users/uetchy/Repos/src/github.com/epic-team/epic-project 51 | ``` 52 | 53 | With `cd`, you can jump to the created project quickly: 54 | 55 | ```bash 56 | cd $(gst new epic-project) 57 | ``` 58 | 59 | It's also good for having a handy alias for this workflow: 60 | 61 | ```bash 62 | newrepo() { 63 | cd $(gst new ${1}) 64 | } 65 | ``` 66 | 67 | ### rm 68 | 69 | Remove a git repository. It also removes the containing directory if the deleted repository was the sole repository the parent directory had. 70 | 71 | ```bash 72 | $ gst rm retired-project 73 | Remove? /Users/uetchy/Repos/src/github.com/uetchy/retired-project [Y/n] 74 | Removed /Users/uetchy/Repos/src/github.com/uetchy/retired-project 75 | Removed /Users/uetchy/Repos/src/github.com/uetchy 76 | ``` 77 | 78 | ### doctor 79 | 80 | Health-check over all repositories. 81 | 82 | ```bash 83 | $ gst doctor 84 | [bitbucket.org/uetchy/scent] git remote origin has changed: 85 | Expected: github.com/uetchy/google-cloud-vision-raspi-sample 86 | Actual: bitbucket.org/uetchy/scent 87 | ``` 88 | 89 | ### update 90 | 91 | `git pull` to all repositories. 92 | 93 | ```bash 94 | $ gst update 95 | /Users/uetchy/Repos/src/github.com/uetchy/gst 96 | Already up-to-date. 97 | ``` 98 | 99 | ### fetch 100 | 101 | `git fetch --tags --prune` to all repositories. 102 | 103 | ```bash 104 | $ gst fetch 105 | /Users/uetchy/Repos/src/github.com/uetchy/gst 106 | * [new branch] dev -> origin/dev 107 | - [deleted] (none) -> origin/test 108 | * [new tag] v1.0.0 -> v1.0.0 109 | ``` 110 | 111 | ## Quick Install 112 | 113 | See [releases](https://github.com/uetchy/gst/releases/latest). 114 | 115 | 116 | 117 | macOS: 118 | 119 | ```bash 120 | brew tap sake.sh/uetchy https://sake.sh/uetchy 121 | brew install gst 122 | ``` 123 | 124 | Linux: 125 | 126 | ```bash 127 | curl -L https://github.com/uetchy/gst/releases/download/v5.0.5/gst_linux_amd64 > /usr/local/bin/gst 128 | chmod +x /usr/local/bin/gst 129 | ``` 130 | 131 | 132 | 133 | ### Run as Docker container 134 | 135 | You can take a glance at what `gst` do before installing the actual binary by running the containerized Docker image. 136 | 137 | ```bash 138 | alias gst="docker run --rm -v \$(ghq root):/ghq -it uetchy/gst" 139 | gst --help 140 | gst list 141 | ``` 142 | 143 | ### Pre-release build 144 | 145 | macOS: 146 | 147 | ```bash 148 | curl -L https://github.com/uetchy/gst/releases/download/pre-release/gst_darwin_amd64 > /usr/local/bin/gst 149 | chmod +x /usr/local/bin/gst 150 | ``` 151 | 152 | Linux: 153 | 154 | ```bash 155 | curl -L https://github.com/uetchy/gst/releases/download/pre-release/gst_linux_amd64 > /usr/local/bin/gst 156 | chmod +x /usr/local/bin/gst 157 | ``` 158 | 159 | ### Head build 160 | 161 | ```bash 162 | go get github.com/uetchy/gst 163 | ``` 164 | 165 | ## Development 166 | 167 | PRs are welcome. 168 | 169 | ```bash 170 | go build 171 | ./gst 172 | ``` 173 | 174 | ### Test 175 | 176 | Docker is required to run tests. 177 | 178 | ```bash 179 | make test 180 | ``` 181 | 182 | ## Contributors 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 |
Yasuaki Uechi
Yasuaki Uechi

💻 📖
NaotoSuzuki
NaotoSuzuki

💻
194 | 195 | 196 | 197 | 198 | 199 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/Songmu/prompter v0.3.0 h1:u4T18daNMg/p37COWQqIhZXVLhwnL5LJin6BU4JD2bs= 3 | github.com/Songmu/prompter v0.3.0/go.mod h1:qXRyRoOsLZIF5fWoylqmM6xtUzwjvV+dg2hxfS3xikM= 4 | github.com/Songmu/prompter v0.4.0 h1:4dEOeAegBsB7xU5kTvo6+YfSAdTD11UjA7FcDk8xk8A= 5 | github.com/Songmu/prompter v0.4.0/go.mod h1:qXRyRoOsLZIF5fWoylqmM6xtUzwjvV+dg2hxfS3xikM= 6 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= 7 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= 8 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= 9 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= 11 | github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 12 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/daviddengcn/go-colortext v0.0.0-20180409174941-186a3d44e920 h1:d/cVoZOrJPJHKH1NdeUjyVAWKp4OpOT+Q+6T1sH7jeU= 15 | github.com/daviddengcn/go-colortext v0.0.0-20180409174941-186a3d44e920/go.mod h1:dv4zxwHi5C/8AeI+4gX4dCWOIvNi7I6JCSX0HvlKPgE= 16 | github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= 17 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 18 | github.com/golangplus/bytes v0.0.0-20160111154220-45c989fe5450 h1:7xqw01UYS+KCI25bMrPxwNYkSns2Db1ziQPpVq99FpE= 19 | github.com/golangplus/bytes v0.0.0-20160111154220-45c989fe5450/go.mod h1:Bk6SMAONeMXrxql8uvOKuAZSu8aM5RUGv+1C6IJaEho= 20 | github.com/golangplus/fmt v0.0.0-20150411045040-2a5d6d7d2995 h1:f5gsjBiF9tRRVomCvrkGMMWI8W1f2OBFar2c5oakAP0= 21 | github.com/golangplus/fmt v0.0.0-20150411045040-2a5d6d7d2995/go.mod h1:lJgMEyOkYFkPcDKwRXegd+iM6E7matEszMG5HhwytU8= 22 | github.com/golangplus/testing v0.0.0-20180327235837-af21d9c3145e h1:KhcknUwkWHKZPbFy2P7jH5LKJ3La+0ZeknkkmrSgqb0= 23 | github.com/golangplus/testing v0.0.0-20180327235837-af21d9c3145e/go.mod h1:0AA//k/eakGydO4jKRoRL2j92ZKSzTgj9tclaCrvXHk= 24 | github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= 25 | github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= 26 | github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= 27 | github.com/motemen/go-gitconfig v0.0.0-20160409144229-d53da5028b75 h1:09IMBvlD5p1nqTaeHsBBs/pCMzfWQp8nNiNMXf+SrDE= 28 | github.com/motemen/go-gitconfig v0.0.0-20160409144229-d53da5028b75/go.mod h1:QXia1CrSbK/p/UxSFPMEy1Jprf2ZiTh3AB03NiNJXpc= 29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 31 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= 32 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 33 | github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= 34 | github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= 35 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 36 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 37 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 38 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 39 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 40 | github.com/urfave/cli v1.22.2 h1:gsqYFH8bb9ekPA12kRo0hfjngWQjkJPlN9R0N78BoUo= 41 | github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 42 | github.com/urfave/cli v1.22.3 h1:FpNT6zq26xNpHZy08emi755QwzLPs6Pukqjlc7RfOMU= 43 | github.com/urfave/cli v1.22.3/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 44 | github.com/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA= 45 | github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 46 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 47 | golang.org/x/crypto v0.0.0-20191202143827-86a70503ff7e/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 48 | golang.org/x/crypto v0.0.0-20191219195013-becbf705a915 h1:aJ0ex187qoXrJHPo8ZasVTASQB7llQP6YeNzgDALPRk= 49 | golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 50 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 51 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 52 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 53 | golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 54 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 55 | golang.org/x/sys v0.0.0-20191128015809-6d18c012aee9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 56 | golang.org/x/sys v0.0.0-20191220220014-0732a990476f h1:72l8qCJ1nGxMGH26QVBVIxKd/D34cfGt0OvrPtpemyY= 57 | golang.org/x/sys v0.0.0-20191220220014-0732a990476f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 58 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 59 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 60 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 61 | -------------------------------------------------------------------------------- /git.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/motemen/go-gitconfig" 12 | ) 13 | 14 | // GitConfig represents git config file 15 | type GitConfig struct { 16 | UserName string `gitconfig:"user.name"` 17 | UserEmail string `gitconfig:"user.email"` 18 | PullRebase bool `gitconfig:"pull.rebase"` 19 | GithubUser string `gitconfig:"github.user"` 20 | RemoteOriginURL string `gitconfig:"remote.origin.url"` 21 | } 22 | 23 | // RepositoryNotFoundError is for when repository not found 24 | type RepositoryNotFoundError struct { 25 | TargetPath string 26 | } 27 | 28 | func (f RepositoryNotFoundError) Error() string { 29 | return "Repository not found or moved: " + f.TargetPath 30 | } 31 | 32 | // NoRemoteSpecifiedError is for when no remote specified 33 | type NoRemoteSpecifiedError struct { 34 | TargetPath string 35 | } 36 | 37 | func (f NoRemoteSpecifiedError) Error() string { 38 | return "No remote repository specified: " + f.TargetPath 39 | } 40 | 41 | // NoCommitsError is for when no commits found 42 | type NoCommitsError struct { 43 | TargetPath string 44 | } 45 | 46 | func (f NoCommitsError) Error() string { 47 | return "Does not have any commits yet: " + f.TargetPath 48 | } 49 | 50 | // GitConfigGet returns git config value by key 51 | func GitConfigGet(targetPath string, key string) (string, error) { 52 | var configFile gitconfig.Config 53 | switch targetPath { 54 | case "global": 55 | configFile = gitconfig.Global 56 | case "local": 57 | configFile = gitconfig.Local 58 | default: 59 | configPath := filepath.Join(targetPath, ".git/config") 60 | configFile = gitconfig.File(configPath) 61 | } 62 | result, err := configFile.GetString(key) 63 | if err != nil { 64 | return "", err 65 | } 66 | 67 | return result, nil 68 | } 69 | 70 | // GitConfigSet will set git config value by key 71 | func GitConfigSet(targetPath string, key string, value string) (string, error) { 72 | out, err := exec.Command("git", "config", "--file", targetPath, "--set", key, value).Output() 73 | return string(out), err 74 | } 75 | 76 | // GitStatus returns git status of certain repository 77 | func GitStatus(targetPath string) ([]string, error) { 78 | if err := os.Chdir(targetPath); err != nil { 79 | return nil, err 80 | } 81 | 82 | out, _ := exec.Command("git", "status", "--porcelain").Output() 83 | if len(out) == 0 { 84 | return nil, errors.New("No status changed") 85 | } 86 | 87 | statuses := strings.Split(string(out), "\n") 88 | 89 | return statuses[:len(statuses)-1], nil 90 | } 91 | 92 | // GitLog is `git log --branches --not --remotes` 93 | func GitLog(targetPath string) ([]string, error) { 94 | if err := os.Chdir(targetPath); err != nil { 95 | return nil, err 96 | } 97 | 98 | out, err := exec.Command("git", "log", "--branches", "--not", "--remotes", "--oneline").CombinedOutput() 99 | if err != nil { 100 | eout := string(out) 101 | if strings.HasPrefix(eout, "does not have any commits yet") { 102 | return nil, &NoCommitsError{targetPath} 103 | } 104 | return nil, err 105 | } 106 | 107 | if len(out) == 0 { 108 | return nil, errors.New("No output") 109 | } 110 | 111 | statuses := strings.Split(strings.TrimSpace(string(out)), "\n") 112 | 113 | return statuses, nil 114 | } 115 | 116 | // GitRemoteAdd run `git remote add` 117 | func GitRemoteAdd(targetPath string, name string, url string) error { 118 | if err := os.Chdir(targetPath); err != nil { 119 | return err 120 | } 121 | 122 | _, err := exec.Command("git", "remote", "add", name, url).Output() 123 | if err != nil { 124 | return err 125 | } 126 | 127 | return nil 128 | } 129 | 130 | // GitRemoteSetURL run `git remote set-url` 131 | func GitRemoteSetURL(targetPath string, name string, url string) error { 132 | if err := os.Chdir(targetPath); err != nil { 133 | return err 134 | } 135 | 136 | _, err := exec.Command("git", "remote", "set-url", name, url).Output() 137 | if err != nil { 138 | return err 139 | } 140 | 141 | return nil 142 | } 143 | 144 | // GitPull pulls remote branch 145 | func GitPull(targetPath string) (string, error) { 146 | if err := os.Chdir(targetPath); err != nil { 147 | return "", err 148 | } 149 | 150 | out, err := exec.Command("git", "pull").CombinedOutput() 151 | if err != nil { 152 | eout := string(out) 153 | if strings.HasPrefix(eout, "conq: repository does not exist.") { 154 | return "", &RepositoryNotFoundError{targetPath} 155 | } else if strings.HasPrefix(eout, "ERROR: Repository not found.") { 156 | return "", &RepositoryNotFoundError{targetPath} 157 | } else if strings.HasPrefix(eout, "fatal: No remote repository specified.") { 158 | return "", &NoRemoteSpecifiedError{targetPath} 159 | } else { 160 | return "", err 161 | } 162 | } 163 | 164 | return string(out), nil 165 | } 166 | 167 | // GitFetch update tags and remove old branches 168 | func GitFetch(targetPath string) (string, error) { 169 | if err := os.Chdir(targetPath); err != nil { 170 | return "", err 171 | } 172 | 173 | out, err := exec.Command("git", "fetch", "--tags", "--prune").CombinedOutput() 174 | if err != nil { 175 | eout := string(out) 176 | if strings.HasPrefix(eout, "conq: repository does not exist.") { 177 | return "", &RepositoryNotFoundError{targetPath} 178 | } else if strings.HasPrefix(eout, "ERROR: Repository not found.") { 179 | return "", &RepositoryNotFoundError{targetPath} 180 | } else if strings.HasPrefix(eout, "fatal: No remote repository specified.") { 181 | return "", &NoRemoteSpecifiedError{targetPath} 182 | } else { 183 | return "", err 184 | } 185 | } 186 | 187 | return string(out), nil 188 | } 189 | 190 | // GitRemoteLocation returns Location header of remote 191 | func GitRemoteLocation(targetURL string) (string, error) { 192 | resp, err := http.Head(targetURL) 193 | if err != nil { 194 | return "", err 195 | } 196 | if resp.StatusCode == 301 { 197 | // Moved permanently 198 | return resp.Header["Location"][0], nil 199 | } else if resp.StatusCode == 404 { 200 | // Not found 201 | return "", &RepositoryNotFoundError{targetURL} 202 | } 203 | return "", nil 204 | } 205 | --------------------------------------------------------------------------------