├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md ├── changelog.yml ├── dependabot.yml └── workflows │ ├── go.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── README.zh-CN.md ├── _examples ├── chlog │ └── main.go ├── images │ ├── chlog-demo.png │ ├── chlog-help.png │ ├── one-branch-info.png │ ├── one-remote-info.png │ ├── repo-status-info.png │ └── simple-repo-info.png └── some.md ├── brinfo ├── matcher.go └── matcher_test.go ├── build └── .keep ├── chlog ├── changelog.go ├── config.go ├── filter.go ├── filter_test.go ├── formatter.go ├── group_match.go ├── group_match_test.go └── parser.go ├── cmd ├── chlog │ ├── go.mod │ ├── go.sum │ └── main.go └── gmoji │ └── main.go ├── cmd_std.go ├── cmds.go ├── cmds_test.go ├── gitutil ├── gitutil.go ├── gitutil_test.go ├── ssh_config.go └── url_parser.go ├── gitw.go ├── gmoji ├── emoji.go ├── emoji_test.go ├── gitmojis.json └── gitmojis.zh-CN.json ├── go.mod ├── go.sum ├── info.go ├── info_branch.go ├── info_remote.go ├── info_status.go ├── info_test.go ├── info_util.go ├── repo.go ├── repo_test.go ├── testdata └── .keep └── util.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: inhere 7 | 8 | --- 9 | 10 | **System (please complete the following information):** 11 | 12 | - OS: `linux` [e.g. linux, macOS] 13 | - GO Version: `1.13` [e.g. `1.13`] 14 | - Pkg Version: `1.1.1` [e.g. `1.1.1`] 15 | 16 | **Describe the bug** 17 | 18 | A clear and concise description of what the bug is. 19 | 20 | **To Reproduce** 21 | 22 | ```go 23 | // go code 24 | ``` 25 | 26 | **Expected behavior** 27 | 28 | A clear and concise description of what you expected to happen. 29 | 30 | **Screenshots** 31 | 32 | If applicable, add screenshots to help explain your problem. 33 | 34 | **Additional context** 35 | 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /.github/changelog.yml: -------------------------------------------------------------------------------- 1 | title: '## Change Log' 2 | # style allow: simple, markdown(mkdown), ghr(gh-release) 3 | style: gh-release 4 | # group names 5 | names: [Refactor, Fixed, Feature, Update, Other] 6 | # if empty will auto fetch by git remote 7 | #repo_url: https://github.com/gookit/gitw 8 | 9 | filters: 10 | # message length should >= 12 11 | - name: msg_len 12 | min_len: 12 13 | # message words should >= 3 14 | - name: words_len 15 | min_len: 3 16 | - name: keyword 17 | keyword: format code 18 | exclude: true 19 | - name: keywords 20 | keywords: format code, action test 21 | exclude: true 22 | 23 | # group match rules 24 | # not matched will use 'Other' group. 25 | rules: 26 | - name: Refactor 27 | start_withs: [refactor, break] 28 | contains: ['refactor:'] 29 | - name: Fixed 30 | start_withs: [fix] 31 | contains: ['fix:'] 32 | - name: Feature 33 | start_withs: [feat, new] 34 | contains: ['feat:'] 35 | - name: Update 36 | start_withs: [update, 'up:'] 37 | contains: ['update:'] 38 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | # Check for updates to GitHub Actions every weekday 13 | interval: "daily" 14 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Unit-Tests 2 | on: 3 | pull_request: 4 | paths: 5 | - 'go.mod' 6 | - '**.go' 7 | - '**.yml' 8 | push: 9 | paths: 10 | - '**.go' 11 | - 'go.mod' 12 | - '**.yml' 13 | 14 | # https://github.com/actions 15 | jobs: 16 | 17 | test: 18 | name: Test on go ${{ matrix.go_version }} 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | go_version: [1.21, 1.19, '1.20'] 23 | 24 | steps: 25 | - name: Check out code 26 | uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | 30 | - run: git fetch --force --tags 31 | 32 | - name: Display Env 33 | run: | 34 | git remote -v 35 | git tag -l 36 | env 37 | 38 | - name: Setup Go Faster 39 | uses: WillAbides/setup-go-faster@v1.14.0 40 | timeout-minutes: 3 41 | with: 42 | go-version: ${{ matrix.go_version }} 43 | 44 | - name: Revive check 45 | uses: docker://morphy/revive-action:v2.3.1 46 | with: 47 | # Exclude patterns, separated by semicolons (optional) 48 | exclude: "./internal/..." 49 | 50 | - name: Run static check 51 | uses: reviewdog/action-staticcheck@v1 52 | if: ${{ github.event_name == 'pull_request'}} 53 | with: 54 | github_token: ${{ secrets.github_token }} 55 | # Change reviewdog reporter if you need [github-pr-check,github-check,github-pr-review]. 56 | reporter: github-pr-check 57 | # Report all results. [added,diff_context,file,nofilter]. 58 | filter_mode: added 59 | # Exit with 1 when it find at least one finding. 60 | fail_on_error: true 61 | 62 | - name: Run tests 63 | run: | 64 | go test -cover ./... 65 | # go run ./cmd/chlog last head 66 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Tag-release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | name: Release new version 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 5 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Setup Go Faster 21 | uses: WillAbides/setup-go-faster@v1.14.0 22 | timeout-minutes: 3 23 | with: 24 | go-version: "*" 25 | 26 | - name: Setup ENV 27 | # https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#setting-an-environment-variable 28 | run: | 29 | echo "RELEASE_TAG=${GITHUB_REF:10}" >> $GITHUB_ENV 30 | echo "RELEASE_NAME=$GITHUB_WORKFLOW" >> $GITHUB_ENV 31 | 32 | - name: Build bin package 33 | run: make build-all 34 | 35 | - name: Generate changelog 36 | run: | 37 | go install ./cmd/chlog 38 | chlog -c .github/changelog.yml -o changelog.md prev last 39 | 40 | # https://github.com/softprops/action-gh-release 41 | - name: Create release and upload assets 42 | uses: softprops/action-gh-release@v2 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | with: 46 | name: ${{ env.RELEASE_TAG }} 47 | tag_name: ${{ env.RELEASE_TAG }} 48 | body_path: changelog.md 49 | token: ${{ secrets.GITHUB_TOKEN }} 50 | files: | 51 | build/chlog-darwin-amd64 52 | build/chlog-linux-amd64 53 | build/chlog-linux-arm 54 | build/chlog-windows-amd64.exe 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.swp 3 | .idea 4 | *.patch 5 | ### Go template 6 | # Binaries for programs and plugins 7 | *.exe 8 | *.exe~ 9 | *.dll 10 | *.so 11 | *.dylib 12 | 13 | # Test binary, build with `go test -c` 14 | *.test 15 | 16 | # Output of the go coverage tool, specifically when used with LiteIDE 17 | *.out 18 | .DS_Store 19 | /build 20 | /vendor 21 | /testdata 22 | /main -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 inhere 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # link https://github.com/humbug/box/blob/master/Makefile 2 | #SHELL = /bin/sh 3 | .DEFAULT_GOAL := help 4 | # 每行命令之前必须有一个tab键。如果想用其他键,可以用内置变量.RECIPEPREFIX 声明 5 | # mac 下这条声明 没起作用 !! 6 | #.RECIPEPREFIX = > 7 | .PHONY: all usage help clean 8 | 9 | # 需要注意的是,每行命令在一个单独的shell中执行。这些Shell之间没有继承关系。 10 | # - 解决办法是将两行命令写在一行,中间用分号分隔。 11 | # - 或者在换行符前加反斜杠转义 \ 12 | 13 | # 接收命令行传入参数 make COMMAND tag=v2.0.4 14 | # TAG=$(tag) 15 | 16 | BIN_NAME=chlog 17 | MAIN_SRC_FILE=cmd/chlog/main.go 18 | ROOT_PACKAGE := main 19 | VERSION=$(shell git for-each-ref refs/tags/ --count=1 --sort=-version:refname --format='%(refname:short)' 1 | sed 's/^v//') 20 | 21 | # Full build flags used when building binaries. Not used for test compilation/execution. 22 | BUILD_FLAGS := -ldflags \ 23 | " -X $(ROOT_PACKAGE).Version=$(VERSION)" 24 | 25 | ##there some make command for the project 26 | ## 27 | 28 | help: 29 | @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' | sed -e 's/: / /' 30 | 31 | ##Available Commands: 32 | 33 | ins2bin: ## Install to GOPATH/bin 34 | go build $(BUILD_FLAGS) -o $(GOPATH)/bin/chlog $(MAIN_SRC_FILE) 35 | chmod +x $(GOPATH)/bin/chlog 36 | 37 | build-all:linux arm win darwin ## Build for Linux,ARM,OSX,Windows 38 | 39 | linux: ## Build for Linux 40 | CGO_ENABLED=$(CGO_ENABLED) GOOS=linux GOARCH=amd64 go build $(BUILD_FLAGS) -o build/$(BIN_NAME)-linux-amd64 $(MAIN_SRC_FILE) 41 | chmod +x build/$(BIN_NAME)-linux-amd64 42 | 43 | arm: ## Build for ARM 44 | CGO_ENABLED=$(CGO_ENABLED) GOOS=linux GOARCH=arm go build $(BUILD_FLAGS) -o build/$(BIN_NAME)-linux-arm $(MAIN_SRC_FILE) 45 | chmod +x build/$(BIN_NAME)-linux-arm 46 | 47 | win: ## Build for Windows 48 | CGO_ENABLED=$(CGO_ENABLED) GOOS=windows GOARCH=amd64 go build $(BUILD_FLAGS) -o build/$(BIN_NAME)-windows-amd64.exe $(MAIN_SRC_FILE) 49 | 50 | darwin: ## Build for OSX 51 | CGO_ENABLED=$(CGO_ENABLED) GOOS=darwin GOARCH=amd64 go build $(BUILD_FLAGS) -o build/$(BIN_NAME)-darwin-amd64 $(MAIN_SRC_FILE) 52 | chmod +x build/$(BIN_NAME)-darwin-amd64 53 | 54 | clean: ## Clean all created artifacts 55 | clean: 56 | git clean --exclude=.idea/ -fdx 57 | 58 | cs-fix: ## Fix code style for all files 59 | cs-fix: 60 | gofmt -w ./ 61 | 62 | cs-diff: ## Display code style error files 63 | cs-diff: 64 | gofmt -l ./ 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gitw 2 | 3 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/gookit/gitw?style=flat-square) 4 | [![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/gookit/gitw)](https://github.com/gookit/gitw) 5 | [![Go Reference](https://pkg.go.dev/badge/github.com/gookit/goutil.svg)](https://pkg.go.dev/github.com/gookit/goutil) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/gookit/gitw)](https://goreportcard.com/report/github.com/gookit/gitw) 7 | [![Unit-Tests](https://github.com/gookit/gitw/workflows/Unit-Tests/badge.svg)](https://github.com/gookit/gitw/actions) 8 | [![Coverage Status](https://coveralls.io/repos/github/gookit/gitw/badge.svg?branch=main)](https://coveralls.io/github/gookit/gitw?branch=main) 9 | 10 | `gitw` - Git command wrapper, generate git changelog, fetch repo information and some git tools. 11 | 12 | - Wrap local `git` commands 13 | - Quickly run `git` commands 14 | - Quickly query repository information 15 | - Quick fetch status, remote, branch ... details 16 | - Quickly generate version changelogs via `git log` 17 | - Allow custom build configuration 18 | - Allow custom build filtering , styles, etc 19 | - can be used directly in GitHub Actions 20 | - Support git-emoji code search and replace render 21 | 22 | > **[中文说明](README.zh-CN.md)** 23 | 24 | ## Install 25 | 26 | > required: go 1.18+, git 2.x 27 | 28 | ```bash 29 | go get github.com/gookit/gitw 30 | ``` 31 | 32 | ## Usage 33 | 34 | ```go 35 | package main 36 | 37 | import ( 38 | "fmt" 39 | 40 | "github.com/gookit/gitw" 41 | ) 42 | 43 | func main() { 44 | // logTxt, err := gitw.ShowLogs("v1.0.2", "v1.0.3") 45 | logTxt := gitw.MustString(gitw.ShowLogs("v1.0.2", "v1.0.3")) 46 | fmt.Println(logTxt) 47 | 48 | // Local Branches 49 | brList := gitw.MustStrings(gitw.Branches()) 50 | fmt.Println(brList) 51 | 52 | // custom create command 53 | 54 | logCmd := gitw.New("log", "-2") 55 | // git.Run() 56 | // txt, err := logCmd.Output() 57 | txt := logCmd.SafeOutput() 58 | 59 | fmt.Println(txt) 60 | } 61 | ``` 62 | 63 | ### With more arguments 64 | 65 | Examples, get commit logs between two sha versions via `git log` 66 | 67 | ```go 68 | logCmd := gitw.Log("--reverse"). 69 | Argf("--pretty=format:\"%s\"", c.cfg.LogFormat) 70 | 71 | if c.cfg.Verbose { 72 | logCmd.OnBeforeExec(gitw.PrintCmdline) 73 | } 74 | 75 | // add custom args. eg: "--no-merges" 76 | logCmd.AddArgs("--no-merges") 77 | 78 | // logCmd.Argf("%s...%s", "v0.1.0", "HEAD") 79 | if sha1 != "" && sha2 != "" { 80 | logCmd.Argf("%s...%s", sha1, sha2) 81 | } 82 | 83 | fmt.Println(logCmd.SafeOutput()) 84 | ``` 85 | 86 | ## Repository 87 | 88 | You can quickly get a git repository information at local. 89 | 90 | ```go 91 | repo := gitw.NewRepo("/path/to/my-repo") 92 | ``` 93 | 94 | ### Status Information 95 | 96 | ```go 97 | si := repo.StatusInfo() 98 | 99 | dump.Println(si) 100 | ``` 101 | 102 | **Output**: 103 | 104 | ![repo-status-info](_examples/images/repo-status-info.png) 105 | 106 | ### Branch Information 107 | 108 | ```go 109 | brInfo := repo.CurBranchInfo() 110 | 111 | dump.Println(brInfo) 112 | ``` 113 | 114 | **Output**: 115 | 116 | ![one-remote-info](_examples/images/one-branch-info.png) 117 | 118 | ### Remote Information 119 | 120 | ```go 121 | rt := repo.DefaultRemoteInfo() 122 | 123 | dump.Println(rt) 124 | ``` 125 | 126 | **Output**: 127 | 128 | ![one-remote-info](_examples/images/one-remote-info.png) 129 | 130 | ### Repo Information 131 | 132 | ```go 133 | dump.Println(repo.Info()) 134 | ``` 135 | 136 | **Output**: 137 | 138 | ![simple-repo-info](_examples/images/simple-repo-info.png) 139 | 140 | ## Changelog 141 | 142 | You can quickly generate changelog by `gitw/chlog` package. 143 | 144 | - Allows custom build configuration. see [.github/changelog.yml](.github/changelog.yml) 145 | - can set filtering, grouping, output styles, etc. 146 | 147 | ### Install 148 | 149 | ```shell 150 | go install github.com/gookit/gitw/cmd/chlog@latest 151 | ``` 152 | 153 | ### Usage 154 | 155 | Please run `chlog -h` to see help: 156 | 157 | ![chlog-help](_examples/images/chlog-help.png) 158 | 159 | **Generate changelog**: 160 | 161 | ```shell 162 | chlog prev last 163 | chlog last head 164 | chlog -c .github/changelog.yml last head 165 | ``` 166 | 167 | **Outputs**: 168 | 169 | ![chlog-demo](_examples/images/chlog-demo.png) 170 | 171 | ### Use on action 172 | 173 | Can use `gitw/chlog` on GitHub actions. It does not depend on the Go environment, 174 | just download the binary files of the corresponding system. 175 | 176 | Example: 177 | 178 | > Full script please see [.github/workflows/release.yml](.github/workflows/release.yml) 179 | 180 | ```yaml 181 | # ... 182 | 183 | steps: 184 | - name: Checkout 185 | uses: actions/checkout@v3 186 | with: 187 | fetch-depth: 0 188 | 189 | - name: Generate changelog 190 | run: | 191 | curl https://github.com/gookit/gitw/releases/latest/download/chlog-linux-amd64 -L -o /usr/local/bin/chlog 192 | chmod a+x /usr/local/bin/chlog 193 | chlog -c .github/changelog.yml -o changelog.md prev last 194 | 195 | ``` 196 | 197 | ### Use in code 198 | 199 | ```go 200 | package main 201 | 202 | import ( 203 | "fmt" 204 | 205 | "github.com/gookit/gitw/chlog" 206 | "github.com/gookit/goutil" 207 | ) 208 | 209 | func main() { 210 | cl := chlog.New() 211 | cl.Formatter = &chlog.MarkdownFormatter{ 212 | RepoURL: "https://github.com/gookit/gitw", 213 | } 214 | cl.WithConfig(func(c *chlog.Config) { 215 | // some settings ... 216 | c.Title = "## Change Log" 217 | }) 218 | 219 | // fetch git log 220 | cl.FetchGitLog("v0.1.0", "HEAD", "--no-merges") 221 | 222 | // do generate 223 | goutil.PanicIfErr(cl.Generate()) 224 | 225 | // dump 226 | fmt.Println(cl.Changelog()) 227 | } 228 | ``` 229 | 230 | ## Commands 231 | 232 | ### Methods in `GitWrap` 233 | 234 | Commands of git, more please see [pkg.go.dev](https://pkg.go.dev/github.com/gookit/gitw#GitWrap) 235 | 236 | ```go 237 | func (gw *GitWrap) Add(args ...string) *GitWrap 238 | func (gw *GitWrap) Annotate(args ...string) *GitWrap 239 | func (gw *GitWrap) Apply(args ...string) *GitWrap 240 | func (gw *GitWrap) Bisect(args ...string) *GitWrap 241 | func (gw *GitWrap) Blame(args ...string) *GitWrap 242 | func (gw *GitWrap) Branch(args ...string) *GitWrap 243 | func (gw *GitWrap) Checkout(args ...string) *GitWrap 244 | func (gw *GitWrap) CherryPick(args ...string) *GitWrap 245 | func (gw *GitWrap) Clean(args ...string) *GitWrap 246 | func (gw *GitWrap) Clone(args ...string) *GitWrap 247 | func (gw *GitWrap) Commit(args ...string) *GitWrap 248 | func (gw *GitWrap) Config(args ...string) *GitWrap 249 | func (gw *GitWrap) Describe(args ...string) *GitWrap 250 | func (gw *GitWrap) Diff(args ...string) *GitWrap 251 | func (gw *GitWrap) Fetch(args ...string) *GitWrap 252 | func (gw *GitWrap) Init(args ...string) *GitWrap 253 | func (gw *GitWrap) Log(args ...string) *GitWrap 254 | func (gw *GitWrap) Merge(args ...string) *GitWrap 255 | func (gw *GitWrap) Pull(args ...string) *GitWrap 256 | func (gw *GitWrap) Push(args ...string) *GitWrap 257 | func (gw *GitWrap) Rebase(args ...string) *GitWrap 258 | func (gw *GitWrap) Reflog(args ...string) *GitWrap 259 | func (gw *GitWrap) Remote(args ...string) *GitWrap 260 | // and more ... 261 | ``` 262 | 263 | ### Commonly git functions 264 | 265 | Git command functions of std: 266 | 267 | ```go 268 | func Alias(name string) string 269 | func AllVars() string 270 | func Branches() ([]string, error) 271 | func CommentChar(text string) (string, error) 272 | func Config(name string) string 273 | func ConfigAll(name string) ([]string, error) 274 | func DataDir() (string, error) 275 | func EditText(data string) string 276 | func Editor() string 277 | func GlobalConfig(name string) (string, error) 278 | func HasDotGitDir(path string) bool 279 | func HasFile(segments ...string) bool 280 | func Head() (string, error) 281 | func Quiet(args ...string) bool 282 | func Ref(ref string) (string, error) 283 | func RefList(a, b string) ([]string, error) 284 | func Remotes() ([]string, error) 285 | func SetGlobalConfig(name, value string) error 286 | func SetWorkdir(dir string) 287 | func ShowDiff(sha string) (string, error) 288 | func ShowLogs(sha1, sha2 string) (string, error) 289 | func Spawn(args ...string) error 290 | func SymbolicFullName(name string) (string, error) 291 | func SymbolicRef(ref string) (string, error) 292 | func Tags(args ...string) ([]string, error) 293 | func Var(name string) string 294 | func Version() (string, error) 295 | func Workdir() (string, error) 296 | func WorkdirName() (string, error) 297 | ``` 298 | 299 | ### Util functions 300 | 301 | ```go 302 | func SetDebug() 303 | func SetDebug(open bool) 304 | func IsDebug() bool 305 | func IsGitCmd(command string) bool 306 | func IsGitCommand(command string) bool 307 | func IsGitDir(dir string) bool 308 | func ParseRemoteURL(URL string, r *RemoteInfo) (err error) 309 | func MustString(s string, err error) string 310 | func MustStrings(ss []string, err error) []string 311 | func FirstLine(output string) string 312 | func OutputLines(output string) []string 313 | func EditText(data string) string 314 | ``` 315 | 316 | ### Remote info 317 | 318 | ```go 319 | // type RemoteInfo 320 | func NewEmptyRemoteInfo(URL string) *RemoteInfo 321 | func NewRemoteInfo(name, url, typ string) (*RemoteInfo, error) 322 | func (r *RemoteInfo) GitURL() string 323 | func (r *RemoteInfo) Invalid() bool 324 | func (r *RemoteInfo) Path() string 325 | func (r *RemoteInfo) RawURLOfHTTP() string 326 | func (r *RemoteInfo) RepoPath() string 327 | func (r *RemoteInfo) String() string 328 | func (r *RemoteInfo) URLOfHTTP() string 329 | func (r *RemoteInfo) URLOfHTTPS() string 330 | func (r *RemoteInfo) Valid() bool 331 | ``` 332 | 333 | ## Refers 334 | 335 | - https://github/phppkg/phpgit 336 | - https://github.com/github/hub 337 | - https://github.com/alibaba/git-repo-go 338 | - https://github.com/carloscuesta/gitmoji/blob/master/packages/gitmojis/src/gitmojis.json 339 | - https://github.com/hooj0/git-emoji-guide 340 | 341 | ## LICENSE 342 | 343 | [MIT](LICENSE) 344 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # Gitw 2 | 3 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/gookit/gitw?style=flat-square) 4 | [![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/gookit/gitw)](https://github.com/gookit/gitw) 5 | [![Go Reference](https://pkg.go.dev/badge/github.com/gookit/goutil.svg)](https://pkg.go.dev/github.com/gookit/goutil) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/gookit/gitw)](https://goreportcard.com/report/github.com/gookit/gitw) 7 | [![Unit-Tests](https://github.com/gookit/gitw/workflows/Unit-Tests/badge.svg)](https://github.com/gookit/gitw/actions) 8 | [![Coverage Status](https://coveralls.io/repos/github/gookit/gitw/badge.svg?branch=main)](https://coveralls.io/github/gookit/gitw?branch=main) 9 | 10 | `gitw` - 包装Git命令方便使用。生成变更记录日志,获取 repo,branch,remote 信息和一些 Git 命令工具。 11 | 12 | > Github https://github.com/gookit/gitw 13 | 14 | - 包装本地 `git` 命令以方便使用 15 | - 快速运行 `git` 子命令,获取信息等 16 | - 快速查询存储库信息 17 | - 获取 status, remote, branch 等详细信息 18 | - 通过 `git log` 快速生成版本变更日志 19 | - 允许自定义生成配置 20 | - 允许自定义生成过滤、样式等 21 | - 可以直接在 GitHub Actions 中使用 22 | - 支持 `git-emoji` code 搜索和替换渲染 23 | 24 | > **[EN-README](README.md)** 25 | 26 | ## 安装 27 | 28 | > 需要: go 1.18+, git 2.x 29 | 30 | ```bash 31 | go get github.com/gookit/gitw 32 | ``` 33 | 34 | ## 使用 35 | 36 | ```go 37 | package main 38 | 39 | import ( 40 | "fmt" 41 | 42 | "github.com/gookit/gitw" 43 | ) 44 | 45 | func main() { 46 | // logTxt, err := gitw.ShowLogs("v1.0.2", "v1.0.3") 47 | logTxt := gitw.MustString(gitw.ShowLogs("v1.0.2", "v1.0.3")) 48 | fmt.Println(logTxt) 49 | 50 | // Local Branches 51 | brList := gitw.MustStrings(gitw.Branches()) 52 | fmt.Println(brList) 53 | 54 | // custom create command 55 | 56 | logCmd := gitw.New("log", "-2") 57 | // git.Run() 58 | // txt, err := logCmd.Output() 59 | txt := logCmd.SafeOutput() 60 | 61 | fmt.Println(txt) 62 | } 63 | ``` 64 | 65 | ### 使用更多参数 66 | 67 | 示例,通过 `git log` 获取两个 sha 版本之间的提交日志 68 | 69 | ```go 70 | logCmd := gitw.Log("--reverse"). 71 | Argf("--pretty=format:\"%s\"", c.cfg.LogFormat) 72 | 73 | if c.cfg.Verbose { 74 | logCmd.OnBeforeExec(gitw.PrintCmdline) 75 | } 76 | 77 | // add custom args. eg: "--no-merges" 78 | logCmd.AddArgs("--no-merges") 79 | 80 | // logCmd.Argf("%s...%s", "v0.1.0", "HEAD") 81 | if sha1 != "" && sha2 != "" { 82 | logCmd.Argf("%s...%s", sha1, sha2) 83 | } 84 | 85 | fmt.Println(logCmd.SafeOutput()) 86 | ``` 87 | 88 | ## 仓库信息 89 | 90 | 可以通过 `gitw` 在本地快速获取 git 存储库信息。 91 | 92 | ```go 93 | repo := gitw.NewRepo("/path/to/my-repo") 94 | ``` 95 | 96 | ### Status 信息 97 | 98 | ```go 99 | si := repo.StatusInfo() 100 | 101 | dump.Println(si) 102 | ``` 103 | 104 | **Output**: 105 | 106 | ![repo-status-info](_examples/images/repo-status-info.png) 107 | 108 | ### Branch 信息 109 | 110 | ```go 111 | brInfo := repo.CurBranchInfo() 112 | 113 | dump.Println(brInfo) 114 | ``` 115 | 116 | **Output**: 117 | 118 | ![one-remote-info](_examples/images/one-branch-info.png) 119 | 120 | ### Remote 信息 121 | 122 | ```go 123 | rt := repo.DefaultRemoteInfo() 124 | 125 | dump.Println(rt) 126 | ``` 127 | 128 | **Output**: 129 | 130 | ![one-remote-info](_examples/images/one-remote-info.png) 131 | 132 | **仓库信息**: 133 | 134 | ```go 135 | dump.Println(repo.Info()) 136 | ``` 137 | 138 | **Output**: 139 | 140 | ![simple-repo-info](_examples/images/simple-repo-info.png) 141 | 142 | ## 生成变更日志 143 | 144 | 可以通过 `gitw/chlog` 包快速生成变更日志。 145 | 146 | - 允许自定义生成配置 请看 [.github/changelog.yml](.github/changelog.yml) 147 | - 可以设置过滤、分组、输出样式等 148 | 149 | ### 安装 150 | 151 | ```shell 152 | go install github.com/gookit/gitw/cmd/chlog@latest 153 | ``` 154 | 155 | ### 使用 156 | 157 | 运行 `chlog -h` 查看帮助: 158 | 159 | ![chlog-help](_examples/images/chlog-help.png) 160 | 161 | **生成变更日志:**: 162 | 163 | ```shell 164 | chlog prev last 165 | chlog last head 166 | chlog -c .github/changelog.yml last head 167 | ``` 168 | 169 | **Outputs**: 170 | 171 | ![chlog-demo](_examples/images/chlog-demo.png) 172 | 173 | ### 在GitHub Action使用 174 | 175 | `gitw/chlog` 可以直接在 `GitHub Actions` 中使用。不依赖 Go 环境,下载对应系统的二进制文件即可。 176 | 177 | 示例: 178 | 179 | > Full script please see [.github/workflows/release.yml](.github/workflows/release.yml) 180 | 181 | ```yaml 182 | # ... 183 | 184 | steps: 185 | - name: Checkout 186 | uses: actions/checkout@v3 187 | with: 188 | fetch-depth: 0 189 | 190 | - name: Generate changelog 191 | run: | 192 | curl https://github.com/gookit/gitw/releases/latest/download/chlog-linux-amd64 -L -o /usr/local/bin/chlog 193 | chmod a+x /usr/local/bin/chlog 194 | chlog -c .github/changelog.yml -o changelog.md prev last 195 | 196 | ``` 197 | 198 | ### 在项目中使用 199 | 200 | 在项目代码中使用 201 | 202 | ```go 203 | package main 204 | 205 | import ( 206 | "fmt" 207 | 208 | "github.com/gookit/gitw/chlog" 209 | "github.com/gookit/goutil" 210 | ) 211 | 212 | func main() { 213 | cl := chlog.New() 214 | cl.Formatter = &chlog.MarkdownFormatter{ 215 | RepoURL: "https://github.com/gookit/gitw", 216 | } 217 | cl.WithConfig(func(c *chlog.Config) { 218 | // some settings ... 219 | c.Title = "## Change Log" 220 | }) 221 | 222 | // fetch git log 223 | cl.FetchGitLog("v0.1.0", "HEAD", "--no-merges") 224 | 225 | // do generate 226 | goutil.PanicIfErr(cl.Generate()) 227 | 228 | // dump 229 | fmt.Println(cl.Changelog()) 230 | } 231 | ``` 232 | 233 | ## Commands 234 | 235 | ### Methods in `GitWrap` 236 | 237 | Commands of git, more please see [pkg.go.dev](https://pkg.go.dev/github.com/gookit/gitw#GitWrap) 238 | 239 | ```go 240 | func (gw *GitWrap) Add(args ...string) *GitWrap 241 | func (gw *GitWrap) Annotate(args ...string) *GitWrap 242 | func (gw *GitWrap) Apply(args ...string) *GitWrap 243 | func (gw *GitWrap) Bisect(args ...string) *GitWrap 244 | func (gw *GitWrap) Blame(args ...string) *GitWrap 245 | func (gw *GitWrap) Branch(args ...string) *GitWrap 246 | func (gw *GitWrap) Checkout(args ...string) *GitWrap 247 | func (gw *GitWrap) CherryPick(args ...string) *GitWrap 248 | func (gw *GitWrap) Clean(args ...string) *GitWrap 249 | func (gw *GitWrap) Clone(args ...string) *GitWrap 250 | func (gw *GitWrap) Commit(args ...string) *GitWrap 251 | func (gw *GitWrap) Config(args ...string) *GitWrap 252 | func (gw *GitWrap) Describe(args ...string) *GitWrap 253 | func (gw *GitWrap) Diff(args ...string) *GitWrap 254 | func (gw *GitWrap) Fetch(args ...string) *GitWrap 255 | func (gw *GitWrap) Init(args ...string) *GitWrap 256 | func (gw *GitWrap) Log(args ...string) *GitWrap 257 | func (gw *GitWrap) Merge(args ...string) *GitWrap 258 | func (gw *GitWrap) Pull(args ...string) *GitWrap 259 | func (gw *GitWrap) Push(args ...string) *GitWrap 260 | func (gw *GitWrap) Rebase(args ...string) *GitWrap 261 | func (gw *GitWrap) Reflog(args ...string) *GitWrap 262 | func (gw *GitWrap) Remote(args ...string) *GitWrap 263 | // and more ... 264 | ``` 265 | 266 | ### Commonly functions 267 | 268 | Git command functions of std: 269 | 270 | ```go 271 | func Alias(name string) string 272 | func AllVars() string 273 | func Branches() ([]string, error) 274 | func CommentChar(text string) (string, error) 275 | func Config(name string) string 276 | func ConfigAll(name string) ([]string, error) 277 | func DataDir() (string, error) 278 | func EditText(data string) string 279 | func Editor() string 280 | func GlobalConfig(name string) (string, error) 281 | func HasDotGitDir(path string) bool 282 | func HasFile(segments ...string) bool 283 | func Head() (string, error) 284 | func Quiet(args ...string) bool 285 | func Ref(ref string) (string, error) 286 | func RefList(a, b string) ([]string, error) 287 | func Remotes() ([]string, error) 288 | func SetGlobalConfig(name, value string) error 289 | func SetWorkdir(dir string) 290 | func ShowDiff(sha string) (string, error) 291 | func ShowLogs(sha1, sha2 string) (string, error) 292 | func Spawn(args ...string) error 293 | func SymbolicFullName(name string) (string, error) 294 | func SymbolicRef(ref string) (string, error) 295 | func Tags(args ...string) ([]string, error) 296 | func Var(name string) string 297 | func Version() (string, error) 298 | func Workdir() (string, error) 299 | func WorkdirName() (string, error) 300 | ``` 301 | 302 | ### Util functions 303 | 304 | ```go 305 | func SetDebug() 306 | func SetDebug(open bool) 307 | func IsDebug() bool 308 | func IsGitCmd(command string) bool 309 | func IsGitCommand(command string) bool 310 | func IsGitDir(dir string) bool 311 | func ParseRemoteURL(URL string, r *RemoteInfo) (err error) 312 | func MustString(s string, err error) string 313 | func MustStrings(ss []string, err error) []string 314 | func FirstLine(output string) string 315 | func OutputLines(output string) []string 316 | func EditText(data string) string 317 | ``` 318 | 319 | ### Remote info 320 | 321 | ```go 322 | // type RemoteInfo 323 | func NewEmptyRemoteInfo(URL string) *RemoteInfo 324 | func NewRemoteInfo(name, url, typ string) (*RemoteInfo, error) 325 | func (r *RemoteInfo) GitURL() string 326 | func (r *RemoteInfo) Invalid() bool 327 | func (r *RemoteInfo) Path() string 328 | func (r *RemoteInfo) RawURLOfHTTP() string 329 | func (r *RemoteInfo) RepoPath() string 330 | func (r *RemoteInfo) String() string 331 | func (r *RemoteInfo) URLOfHTTP() string 332 | func (r *RemoteInfo) URLOfHTTPS() string 333 | func (r *RemoteInfo) Valid() bool 334 | ``` 335 | 336 | ## Refer 337 | 338 | - https://github/phppkg/phpgit 339 | - https://github.com/github/hub 340 | - https://github.com/alibaba/git-repo-go 341 | 342 | ## LICENSE 343 | 344 | [MIT](LICENSE) 345 | -------------------------------------------------------------------------------- /_examples/chlog/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gookit/gitw/chlog" 7 | "github.com/gookit/goutil" 8 | ) 9 | 10 | // run: go run ./_examples/chlog 11 | func main() { 12 | cl := chlog.New() 13 | cl.Formatter = &chlog.MarkdownFormatter{ 14 | RepoURL: "https://github.com/gookit/gitw", 15 | } 16 | 17 | // with some settings ... 18 | cl.WithConfigFn(func(c *chlog.Config) { 19 | c.GroupPrefix = "\n### " 20 | c.GroupSuffix = "\n" 21 | }) 22 | 23 | // fetch git log 24 | cl.FetchGitLog("v0.1.0", "HEAD", "--no-merges") 25 | 26 | // do generate 27 | goutil.PanicIfErr(cl.Generate()) 28 | 29 | // dump 30 | fmt.Println(cl.Changelog()) 31 | } 32 | -------------------------------------------------------------------------------- /_examples/images/chlog-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gookit/gitw/ba0255b69488a2d364d6145170c3afdfe1f55e98/_examples/images/chlog-demo.png -------------------------------------------------------------------------------- /_examples/images/chlog-help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gookit/gitw/ba0255b69488a2d364d6145170c3afdfe1f55e98/_examples/images/chlog-help.png -------------------------------------------------------------------------------- /_examples/images/one-branch-info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gookit/gitw/ba0255b69488a2d364d6145170c3afdfe1f55e98/_examples/images/one-branch-info.png -------------------------------------------------------------------------------- /_examples/images/one-remote-info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gookit/gitw/ba0255b69488a2d364d6145170c3afdfe1f55e98/_examples/images/one-remote-info.png -------------------------------------------------------------------------------- /_examples/images/repo-status-info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gookit/gitw/ba0255b69488a2d364d6145170c3afdfe1f55e98/_examples/images/repo-status-info.png -------------------------------------------------------------------------------- /_examples/images/simple-repo-info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gookit/gitw/ba0255b69488a2d364d6145170c3afdfe1f55e98/_examples/images/simple-repo-info.png -------------------------------------------------------------------------------- /_examples/some.md: -------------------------------------------------------------------------------- 1 | # git commands 2 | 3 | get commit for a tag: 4 | 5 | ```shell 6 | % git rev-parse refs/tags/v0.2.0 7 | a0e902f129d1f9dbf2190ee89e9fc6fd387cb885 8 | 9 | ``` 10 | 11 | -------------------------------------------------------------------------------- /brinfo/matcher.go: -------------------------------------------------------------------------------- 1 | package brinfo 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/gookit/goutil/strutil" 9 | ) 10 | 11 | // BranchMatcher interface 12 | type BranchMatcher interface { 13 | fmt.Stringer 14 | // Match branch name, no remote prefix 15 | Match(branch string) bool 16 | } 17 | 18 | // ---------- matcher implement ---------- 19 | 20 | // ContainsMatch handle contains matching 21 | type ContainsMatch struct { 22 | pattern string 23 | } 24 | 25 | // NewContainsMatch create a new contains matcher 26 | func NewContainsMatch(pattern string) BranchMatcher { 27 | return &ContainsMatch{pattern: pattern} 28 | } 29 | 30 | // Match branch name by contains pattern 31 | func (c *ContainsMatch) Match(branch string) bool { 32 | return strings.Contains(branch, c.pattern) 33 | } 34 | 35 | // String get string 36 | func (c *ContainsMatch) String() string { 37 | return "contains: " + c.pattern 38 | } 39 | 40 | // PrefixMatch handle prefix matching 41 | type PrefixMatch struct { 42 | pattern string 43 | } 44 | 45 | // NewPrefixMatch create a new prefix matcher 46 | func NewPrefixMatch(pattern string) BranchMatcher { 47 | return &PrefixMatch{pattern: pattern} 48 | } 49 | 50 | // Match branch name by prefix pattern 51 | func (p *PrefixMatch) Match(branch string) bool { 52 | return strings.HasPrefix(branch, p.pattern) 53 | } 54 | 55 | // String get string 56 | func (p *PrefixMatch) String() string { 57 | return "prefix: " + p.pattern 58 | } 59 | 60 | // SuffixMatch handle suffix matching 61 | type SuffixMatch struct { 62 | pattern string 63 | } 64 | 65 | // NewSuffixMatch create a new suffix matcher 66 | func NewSuffixMatch(pattern string) BranchMatcher { 67 | return &SuffixMatch{pattern: pattern} 68 | } 69 | 70 | // Match branch name by suffix pattern 71 | func (s *SuffixMatch) Match(branch string) bool { 72 | return strings.HasSuffix(branch, s.pattern) 73 | } 74 | 75 | // String get string 76 | func (s *SuffixMatch) String() string { 77 | return "suffix: " + s.pattern 78 | } 79 | 80 | // GlobMatch handle glob matching 81 | type GlobMatch struct { 82 | pattern string 83 | } 84 | 85 | // NewGlobMatch create a new glob matcher 86 | func NewGlobMatch(pattern string) BranchMatcher { 87 | return &GlobMatch{pattern: pattern} 88 | } 89 | 90 | // Match branch name by glob pattern 91 | func (g *GlobMatch) Match(branch string) bool { 92 | return strutil.GlobMatch(g.pattern, branch) 93 | } 94 | 95 | // String get string 96 | func (g *GlobMatch) String() string { 97 | return "glob: " + g.pattern 98 | } 99 | 100 | // RegexMatch handle regex matching 101 | type RegexMatch struct { 102 | pattern string 103 | regex *regexp.Regexp 104 | } 105 | 106 | // NewRegexMatch create a new regex matcher 107 | func NewRegexMatch(pattern string) BranchMatcher { 108 | return &RegexMatch{ 109 | pattern: pattern, 110 | regex: regexp.MustCompile(pattern), 111 | } 112 | } 113 | 114 | // Match branch name by regex pattern 115 | func (r *RegexMatch) Match(branch string) bool { 116 | return r.regex.MatchString(branch) 117 | } 118 | 119 | // String get string 120 | func (r *RegexMatch) String() string { 121 | return "regex: " + r.pattern 122 | } 123 | 124 | // NewMatcher create a branch matcher by type and pattern 125 | func NewMatcher(pattern string, typ ...string) BranchMatcher { 126 | var typName string 127 | if len(typ) > 0 && typ[0] != "" { 128 | typName = typ[0] 129 | } else if strings.Contains(pattern, ":") { 130 | typName, pattern = strutil.TrimCut(pattern, ":") 131 | } 132 | 133 | switch typName { 134 | case "contains", "contain", "has": 135 | return NewContainsMatch(pattern) 136 | case "prefix", "start", "pfx": 137 | return NewPrefixMatch(pattern) 138 | case "suffix", "end", "sfx": 139 | return NewSuffixMatch(pattern) 140 | case "regex", "regexp", "reg", "re": 141 | return NewRegexMatch(pattern) 142 | case "glob", "pattern", "pat": 143 | return NewGlobMatch(pattern) 144 | default: // default is glob mode. 145 | return NewGlobMatch(pattern) 146 | } 147 | } 148 | 149 | // NewBranchMatcher create a new branch matcher 150 | func NewBranchMatcher(pattern string, regex bool) BranchMatcher { 151 | if regex { 152 | return NewRegexMatch(pattern) 153 | } 154 | return NewGlobMatch(pattern) 155 | } 156 | 157 | // ---------- multi-matcher wrapper ---------- 158 | 159 | const ( 160 | // MatchAny match any one as success(default) 161 | MatchAny = iota 162 | // MatchAll match all as success 163 | MatchAll 164 | ) 165 | 166 | // MultiMatcher match branch name by multi matcher 167 | type MultiMatcher struct { 168 | // match mode. default is MatchAny 169 | mode uint8 170 | // matchers list 171 | matchers []BranchMatcher 172 | } 173 | 174 | // NewMulti create a multi matcher by matchers 175 | func NewMulti(ms ...BranchMatcher) *MultiMatcher { 176 | return &MultiMatcher{matchers: ms} 177 | } 178 | 179 | // QuickMulti quick create a multi matcher by type and patterns 180 | // 181 | // Usage: 182 | // 183 | // m := QuickMulti("contains:feat", "prefix:fix", "suffix:bug") 184 | // m := QuickMulti("contains:feat", "prefix:fix", "suffix:bug").WithMode(MatchAll) 185 | func QuickMulti(typWithPatterns ...string) *MultiMatcher { 186 | matchers := make([]BranchMatcher, len(typWithPatterns)) 187 | 188 | for i, typWithPattern := range typWithPatterns { 189 | matchers[i] = NewMatcher(typWithPattern) 190 | } 191 | return NewMulti(matchers...) 192 | } 193 | 194 | // WithMode set mode 195 | func (m *MultiMatcher) WithMode(mode uint8) *MultiMatcher { 196 | m.mode = mode 197 | return m 198 | } 199 | 200 | // Len of multi matcher 201 | func (m *MultiMatcher) Len() int { 202 | return len(m.matchers) 203 | } 204 | 205 | // IsEmpty check 206 | func (m *MultiMatcher) IsEmpty() bool { 207 | return len(m.matchers) == 0 208 | } 209 | 210 | // Add matcher to multi matcher 211 | func (m *MultiMatcher) Add(ms ...BranchMatcher) { 212 | m.matchers = append(m.matchers, ms...) 213 | } 214 | 215 | // Match branch name by multi matcher 216 | func (m *MultiMatcher) Match(branch string) bool { 217 | // match one 218 | if m.mode == MatchAny { 219 | for _, matcher := range m.matchers { 220 | if matcher.Match(branch) { 221 | return true 222 | } 223 | } 224 | return false 225 | } 226 | 227 | // match all 228 | for _, matcher := range m.matchers { 229 | if !matcher.Match(branch) { 230 | return false 231 | } 232 | } 233 | return true 234 | } 235 | 236 | // String get string 237 | func (m *MultiMatcher) String() string { 238 | ss := make([]string, len(m.matchers)) 239 | for i, matcher := range m.matchers { 240 | ss[i] = matcher.String() 241 | } 242 | return "multi: " + strings.Join(ss, ", ") 243 | } 244 | -------------------------------------------------------------------------------- /brinfo/matcher_test.go: -------------------------------------------------------------------------------- 1 | package brinfo_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gookit/gitw/brinfo" 7 | "github.com/gookit/goutil/testutil/assert" 8 | ) 9 | 10 | func TestGlobMatch_Match(t *testing.T) { 11 | m := brinfo.NewMatcher("fea*") 12 | assert.True(t, m.Match("fea-1")) 13 | assert.True(t, m.Match("fea_dev")) 14 | assert.False(t, m.Match("fix_2")) 15 | 16 | m = brinfo.NewMatcher("fix", "prefix") 17 | assert.False(t, m.Match("fea-1")) 18 | assert.False(t, m.Match("fea_dev")) 19 | assert.True(t, m.Match("fix_2")) 20 | 21 | m = brinfo.NewMatcher(`reg:^ma\w+`) 22 | assert.True(t, m.Match("main")) 23 | assert.True(t, m.Match("master")) 24 | assert.False(t, m.Match("x-main")) 25 | 26 | m = brinfo.NewGlobMatch("*new*") 27 | assert.True(t, m.Match("fea/new_br001")) 28 | } 29 | 30 | func TestNewMulti(t *testing.T) { 31 | m := brinfo.NewMulti( 32 | brinfo.NewGlobMatch("fea*"), 33 | brinfo.NewPrefixMatch("fix"), 34 | // brinfo.NewContainsMatch("main"), 35 | brinfo.NewSuffixMatch("-dev"), 36 | ) 37 | m.Add(brinfo.NewContainsMatch("main")) 38 | 39 | assert.False(t, m.IsEmpty()) 40 | assert.True(t, m.Match("fea-1")) 41 | assert.True(t, m.Match("fea_dev")) 42 | assert.True(t, m.Match("fix_2")) 43 | assert.True(t, m.Match("main")) 44 | assert.True(t, m.Match("some/fea-dev")) 45 | 46 | m = brinfo.QuickMulti("start:fix", "end:-dev") 47 | assert.True(t, m.Match("fix_23")) 48 | assert.True(t, m.Match("fea23-dev")) 49 | assert.NotEmpty(t, m.String()) 50 | assert.Eq(t, 2, m.Len()) 51 | 52 | m.WithMode(brinfo.MatchAll) 53 | 54 | assert.False(t, m.Match("fix_23")) 55 | assert.False(t, m.Match("fea23-dev")) 56 | assert.True(t, m.Match("fix-23-dev")) 57 | } 58 | -------------------------------------------------------------------------------- /build/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gookit/gitw/ba0255b69488a2d364d6145170c3afdfe1f55e98/build/.keep -------------------------------------------------------------------------------- /chlog/changelog.go: -------------------------------------------------------------------------------- 1 | package chlog 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "strings" 7 | 8 | "github.com/gookit/gitw" 9 | "github.com/gookit/goutil/strutil" 10 | ) 11 | 12 | // ErrEmptyLogText error 13 | var ErrEmptyLogText = errors.New("empty git log text for parse") 14 | 15 | // LogItem struct 16 | type LogItem struct { 17 | HashID string // %H %h 18 | ParentID string // %P %p 19 | Msg string // %s 20 | Date string // %ci 21 | Author string // %an 22 | Committer string // %cn 23 | } 24 | 25 | // AbbrevID get abbrev commit ID 26 | func (l *LogItem) AbbrevID() string { 27 | if l.HashID == "" { 28 | return "" 29 | } 30 | 31 | return strutil.Substr(l.HashID, 0, 7) 32 | } 33 | 34 | // Username get commit username. 35 | func (l *LogItem) Username() string { 36 | if l.Author != "" { 37 | return l.Author 38 | } 39 | return l.Committer 40 | } 41 | 42 | // Changelog struct 43 | type Changelog struct { 44 | cfg *Config 45 | // handle mark 46 | parsed, generated bool 47 | // the generated change log text 48 | changelog string 49 | // The git log output. eg: `git log --pretty="format:%H"` 50 | // see https://devhints.io/git-log-format 51 | // and https://git-scm.com/docs/pretty-formats 52 | logText string 53 | // the parsed log items 54 | logItems []*LogItem 55 | // the formatted lines by formatter 56 | // {group: [line, line, ...], ...} 57 | formatted map[string][]string 58 | // the valid commit log count after parse and formatted. 59 | logCount int 60 | // LineParser can custom log line parser 61 | LineParser LineParser 62 | // ItemFilters The parsed log item filters 63 | ItemFilters []ItemFilter 64 | // Formatter The item formatter. format each item to string 65 | Formatter Formatter 66 | } 67 | 68 | // New object 69 | func New() *Changelog { 70 | return &Changelog{ 71 | cfg: NewDefaultConfig(), 72 | } 73 | } 74 | 75 | // NewWithGitLog new object with git log output text 76 | func NewWithGitLog(gitLogOut string) *Changelog { 77 | cl := New() 78 | cl.SetLogText(gitLogOut) 79 | return cl 80 | } 81 | 82 | // NewWithConfig object 83 | func NewWithConfig(cfg *Config) *Changelog { 84 | return &Changelog{ 85 | cfg: cfg, 86 | } 87 | } 88 | 89 | // WithFn config the object 90 | func (c *Changelog) WithFn(fn func(c *Changelog)) *Changelog { 91 | fn(c) 92 | return c 93 | } 94 | 95 | // WithConfig with new config object 96 | func (c *Changelog) WithConfig(cfg *Config) *Changelog { 97 | c.cfg = cfg 98 | return c 99 | } 100 | 101 | // WithConfigFn config the object 102 | func (c *Changelog) WithConfigFn(fn func(cfg *Config)) *Changelog { 103 | fn(c.cfg) 104 | return c 105 | } 106 | 107 | // SetLogText from git log 108 | func (c *Changelog) SetLogText(gitLogOut string) { 109 | c.logText = strings.TrimSpace(gitLogOut) 110 | } 111 | 112 | // LogIsEmpty check by git log 113 | func (c *Changelog) LogIsEmpty() bool { 114 | return len(c.logText) == 0 115 | } 116 | 117 | // FetchGitLog fetch log data by git log 118 | func (c *Changelog) FetchGitLog(sha1, sha2 string, moreArgs ...string) *Changelog { 119 | logCmd := gitw.Log("--reverse"). 120 | Argf("--pretty=format:\"%s\"", c.cfg.LogFormat) 121 | 122 | if c.cfg.Verbose { 123 | logCmd.PrintCmdline() 124 | } 125 | 126 | // add custom args. eg: "--no-merges" 127 | logCmd.AddArgs(moreArgs) 128 | 129 | // logCmd.Argf("%s...%s", "v0.1.0", "HEAD") 130 | if sha1 != "" && sha2 != "" { 131 | logCmd.Argf("%s...%s", sha1, sha2) 132 | } 133 | 134 | c.SetLogText(logCmd.SafeOutput()) 135 | return c 136 | } 137 | 138 | // prepare something 139 | func (c *Changelog) prepare() { 140 | if c.Formatter == nil { 141 | c.Formatter = c.cfg.CreateFormatter() 142 | } 143 | 144 | c.ItemFilters = c.cfg.CreateFilters() 145 | } 146 | 147 | // ------------------------------------------------------------------- 148 | // parse git log 149 | // ------------------------------------------------------------------- 150 | 151 | // Parse the loaded git log text 152 | func (c *Changelog) Parse() (err error) { 153 | if c.parsed { 154 | return 155 | } 156 | 157 | c.parsed = true 158 | c.prepare() 159 | 160 | str := c.logText 161 | if str == "" { 162 | return ErrEmptyLogText 163 | } 164 | 165 | // ensure parser exists 166 | if c.LineParser == nil { 167 | c.LineParser = BuiltInParser 168 | } 169 | 170 | parser := c.LineParser 171 | msgIDMap := make(map[string]int) 172 | 173 | for _, line := range strings.Split(str, "\n") { 174 | line = strings.TrimSpace(line) 175 | if line == "" { 176 | continue 177 | } 178 | 179 | line = strings.Trim(line, "\"' ") 180 | if line == "" { 181 | continue 182 | } 183 | 184 | // parse line 185 | li := parser.Parse(line, c) 186 | if li == nil { 187 | continue 188 | } 189 | 190 | // item filters 191 | if !c.applyItemFilters(li) { 192 | continue 193 | } 194 | 195 | // remove repeat msg item 196 | if c.cfg.RmRepeat { 197 | msgID := strutil.Md5(li.Msg) 198 | if _, ok := msgIDMap[msgID]; ok { 199 | continue 200 | } 201 | 202 | msgIDMap[msgID] = 1 203 | } 204 | 205 | c.logItems = append(c.logItems, li) 206 | } 207 | 208 | return 209 | } 210 | 211 | func (c *Changelog) applyItemFilters(li *LogItem) bool { 212 | for _, filter := range c.ItemFilters { 213 | if !filter.Handle(li) { 214 | return false 215 | } 216 | } 217 | return true 218 | } 219 | 220 | // ------------------------------------------------------------------- 221 | // generate and export 222 | // ------------------------------------------------------------------- 223 | 224 | // Generate the changelog by parsed log items 225 | func (c *Changelog) Generate() (err error) { 226 | if c.generated { 227 | return 228 | } 229 | 230 | c.generated = true 231 | 232 | // ensure parse 233 | if err = c.Parse(); err != nil { 234 | return err 235 | } 236 | 237 | // format parsed items 238 | groupNames := c.formatLogItems() 239 | groupCount := len(groupNames) 240 | 241 | var outLines []string 242 | // first add title 243 | if c.cfg.Title != "" { 244 | outLines = append(outLines, c.cfg.Title) 245 | } 246 | 247 | // use sorted names for-each 248 | for _, grpName := range c.cfg.Names { 249 | list := c.formatted[grpName] 250 | if len(list) == 0 { 251 | continue 252 | } 253 | 254 | // if only one group, not render group name. 255 | if groupCount > 1 { 256 | outLines = append(outLines, c.cfg.GroupPrefix+grpName+c.cfg.GroupSuffix) 257 | } 258 | 259 | outLines = append(outLines, strings.Join(list, "\n")) 260 | } 261 | 262 | c.changelog = strings.Join(outLines, "\n") 263 | return 264 | } 265 | 266 | func (c *Changelog) formatLogItems() map[string]int { 267 | if c.Formatter == nil { 268 | c.Formatter = &SimpleFormatter{} 269 | } 270 | 271 | // init field 272 | c.formatted = make(map[string][]string, len(c.cfg.Names)) 273 | 274 | groupMap := make(map[string]int, len(c.logItems)) 275 | for _, li := range c.logItems { 276 | group, fmtLine := c.Formatter.Format(li) 277 | // invalid line 278 | if fmtLine == "" { 279 | continue 280 | } 281 | 282 | if group == "" { 283 | group = DefaultGroup 284 | } 285 | 286 | c.logCount++ 287 | groupMap[group] = 1 288 | 289 | if list, ok := c.formatted[group]; ok { 290 | c.formatted[group] = append(list, fmtLine) 291 | } else { 292 | c.formatted[group] = []string{fmtLine} 293 | } 294 | } 295 | 296 | return groupMap 297 | } 298 | 299 | // String get generated change log string 300 | func (c *Changelog) String() string { 301 | return c.changelog 302 | } 303 | 304 | // WriteTo changelog to the writer 305 | func (c *Changelog) WriteTo(w io.Writer) (int64, error) { 306 | n, err := io.WriteString(w, c.changelog) 307 | return int64(n), err 308 | } 309 | 310 | // Changelog get generated change log string 311 | func (c *Changelog) Changelog() string { 312 | return c.changelog 313 | } 314 | 315 | // Formatted get formatted change log line 316 | // func (c *Changelog) Formatted() []string { 317 | // return c.formatted 318 | // } 319 | 320 | // Config get 321 | func (c *Changelog) Config() *Config { 322 | return c.cfg 323 | } 324 | 325 | // LogCount get 326 | func (c *Changelog) LogCount() int { 327 | return c.logCount 328 | } 329 | -------------------------------------------------------------------------------- /chlog/config.go: -------------------------------------------------------------------------------- 1 | package chlog 2 | 3 | import ( 4 | "github.com/gookit/goutil/maputil" 5 | "github.com/gookit/goutil/strutil" 6 | ) 7 | 8 | // Config struct 9 | type Config struct { 10 | // Title string for formatted text. eg: "## Change Log" 11 | Title string `json:"title" yaml:"title"` 12 | // RepoURL repo URL address 13 | RepoURL string `json:"repo_url" yaml:"repo_url"` 14 | // Style name. allow: simple, markdown, ghr 15 | Style string `json:"style" yaml:"style"` 16 | // LogFormat built-in log format string. 17 | // 18 | // use on the `git log --pretty="format:%H"`. 19 | // 20 | // see consts LogFmt*, eg: LogFmtHs 21 | LogFormat string `json:"log_format" yaml:"log_format"` 22 | // GroupPrefix string. eg: '### ' 23 | GroupPrefix string `yaml:"group_prefix"` 24 | // GroupPrefix string. 25 | GroupSuffix string `yaml:"group_suffix"` 26 | // NoGroup Not output group name line. 27 | NoGroup bool `yaml:"no_group"` 28 | // RmRepeat remove repeated log by message 29 | RmRepeat bool `json:"rm_repeat" yaml:"rm_repeat"` 30 | // Verbose show more information 31 | Verbose bool `json:"verbose" yaml:"verbose"` 32 | // Names define group names and sort 33 | Names []string `json:"names" yaml:"names"` 34 | // Rules for match group 35 | Rules []Rule `json:"rules" yaml:"rules"` 36 | // Filters for filtering 37 | Filters []maputil.Data `json:"filters" yaml:"filters"` 38 | } 39 | 40 | // NewDefaultConfig instance 41 | func NewDefaultConfig() *Config { 42 | return &Config{ 43 | Title: "## Change Log", 44 | RmRepeat: true, 45 | LogFormat: LogFmtHs, 46 | GroupPrefix: "\n### ", 47 | GroupSuffix: "\n", 48 | } 49 | } 50 | 51 | // Create Changelog 52 | func (c *Config) Create() *Changelog { 53 | cl := NewWithConfig(c) 54 | 55 | return cl 56 | } 57 | 58 | // CreateFilters for Changelog 59 | func (c *Config) CreateFilters() []ItemFilter { 60 | if len(c.Filters) == 0 { 61 | return nil 62 | } 63 | 64 | fls := make([]ItemFilter, 0, len(c.Filters)) 65 | /* 66 | - name: keywords 67 | keywords: ['format code'] 68 | exclude: true 69 | */ 70 | for _, rule := range c.Filters { 71 | name := rule["name"] 72 | if name == "" { 73 | continue 74 | } 75 | 76 | switch name { 77 | case FilterMsgLen: 78 | ln := rule.Int("min_len") 79 | if ln <= 0 { 80 | continue 81 | } 82 | 83 | fls = append(fls, MsgLenFilter(ln)) 84 | case FilterWordsLen: 85 | ln := rule.Int("min_len") 86 | if ln <= 0 { 87 | continue 88 | } 89 | 90 | fls = append(fls, WordsLenFilter(ln)) 91 | case FilterKeyword: 92 | str := rule.Str("keyword") 93 | if len(str) <= 0 { 94 | continue 95 | } 96 | 97 | fls = append(fls, KeywordFilter(str, rule.Bool("exclude"))) 98 | case FilterKeywords: 99 | str := rule.Str("keywords") 100 | ss := strutil.Split(str, ",") 101 | if len(ss) <= 0 { 102 | continue 103 | } 104 | 105 | fls = append(fls, KeywordsFilter(ss, rule.Bool("exclude"))) 106 | } 107 | } 108 | 109 | return fls 110 | } 111 | 112 | // CreateFormatter for Changelog 113 | func (c *Config) CreateFormatter() Formatter { 114 | sf := &SimpleFormatter{} 115 | ns := c.Names 116 | 117 | matcher := NewDefaultMatcher() 118 | if len(c.Rules) > 0 { 119 | if len(c.Names) == 0 { 120 | ns = maputil.Keys(c.Rules) 121 | } 122 | 123 | matcher = &RuleMatcher{Rules: c.Rules} 124 | } 125 | 126 | if len(ns) > 0 { 127 | matcher.Names = ns 128 | } 129 | 130 | c.Names = matcher.Names 131 | sf.GroupMatch = matcher 132 | 133 | switch c.Style { 134 | case FormatterMarkdown, "mkdown", "mkDown", "mkd", "md": 135 | return &MarkdownFormatter{ 136 | RepoURL: c.RepoURL, 137 | SimpleFormatter: *sf, 138 | } 139 | case FormatterGhRelease, "gh-release", "ghRelease", "gh": 140 | f := &GHReleaseFormatter{} 141 | f.RepoURL = c.RepoURL 142 | f.SimpleFormatter = *sf 143 | return f 144 | default: 145 | return sf 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /chlog/filter.go: -------------------------------------------------------------------------------- 1 | package chlog 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gookit/goutil/strutil" 7 | ) 8 | 9 | // ItemFilter interface 10 | type ItemFilter interface { 11 | // Handle filtering 12 | Handle(li *LogItem) bool 13 | } 14 | 15 | // ItemFilterFunc define. return false to filter item. 16 | // type LineFilterFunc func(line string) bool 17 | type ItemFilterFunc func(li *LogItem) bool 18 | 19 | // Handle filtering 20 | func (f ItemFilterFunc) Handle(li *LogItem) bool { 21 | return f(li) 22 | } 23 | 24 | // built in filters 25 | const ( 26 | FilterMsgLen = "msg_len" 27 | FilterWordsLen = "words_len" 28 | FilterKeyword = "keyword" 29 | FilterKeywords = "keywords" 30 | ) 31 | 32 | // MsgLenFilter handler 33 | func MsgLenFilter(minLen int) ItemFilterFunc { 34 | return func(li *LogItem) bool { 35 | return len(li.Msg) > minLen 36 | } 37 | } 38 | 39 | // WordsLenFilter handler 40 | func WordsLenFilter(minLen int) ItemFilterFunc { 41 | return func(li *LogItem) bool { 42 | return len(strutil.Split(li.Msg, " ")) > minLen 43 | } 44 | } 45 | 46 | // KeywordFilter filter log item by keyword 47 | func KeywordFilter(kw string, exclude bool) ItemFilterFunc { 48 | return func(li *LogItem) bool { 49 | has := strings.Contains(li.Msg, kw) 50 | 51 | if exclude { 52 | return !has 53 | } 54 | return has 55 | } 56 | } 57 | 58 | // KeywordsFilter filter log item by keywords 59 | func KeywordsFilter(kws []string, exclude bool) ItemFilterFunc { 60 | return func(li *LogItem) bool { 61 | for _, kw := range kws { 62 | if strings.Contains(li.Msg, kw) { 63 | return !exclude 64 | } 65 | } 66 | 67 | return exclude 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /chlog/filter_test.go: -------------------------------------------------------------------------------- 1 | package chlog_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gookit/gitw/chlog" 7 | "github.com/gookit/goutil/testutil/assert" 8 | ) 9 | 10 | func TestKeywordsFilter(t *testing.T) { 11 | fl := chlog.KeywordsFilter([]string{"format code", "action test"}, true) 12 | 13 | li := &chlog.LogItem{Msg: "chore: up some for action tests 3"} 14 | assert.False(t, fl(li)) 15 | 16 | li = &chlog.LogItem{Msg: "chore: fix gh action script error"} 17 | assert.True(t, fl(li)) 18 | } 19 | -------------------------------------------------------------------------------- /chlog/formatter.go: -------------------------------------------------------------------------------- 1 | package chlog 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Formatter interface 8 | type Formatter interface { 9 | // MatchGroup from log msg 10 | MatchGroup(msg string) (group string) 11 | // Format the log item to line 12 | Format(li *LogItem) (group, fmtLine string) 13 | } 14 | 15 | // GroupMatcher interface 16 | type GroupMatcher interface { 17 | // Match group from log msg(has been trimmed) 18 | Match(msg string) (group string) 19 | } 20 | 21 | // built-in formatters 22 | const ( 23 | FormatterSimple = "simple" 24 | FormatterMarkdown = "markdown" 25 | FormatterGhRelease = "ghr" 26 | ) 27 | 28 | // SimpleFormatter struct 29 | type SimpleFormatter struct { 30 | // GroupMatch group match handler. 31 | GroupMatch GroupMatcher 32 | } 33 | 34 | // MatchGroup from log msg 35 | func (f *SimpleFormatter) MatchGroup(msg string) (group string) { 36 | if f.GroupMatch != nil { 37 | return f.GroupMatch.Match(msg) 38 | } 39 | 40 | return DefaultMatcher.Match(msg) 41 | } 42 | 43 | // Format the log item to line 44 | func (f *SimpleFormatter) Format(li *LogItem) (group, fmtLine string) { 45 | fmtLine = " - " 46 | if li.HashID != "" { 47 | fmtLine += li.AbbrevID() + " " 48 | } 49 | 50 | group = f.MatchGroup(li.Msg) 51 | 52 | fmtLine += li.Msg 53 | if user := li.Username(); user != "" { 54 | fmtLine += " by(" + user + ")" 55 | } 56 | return 57 | } 58 | 59 | // MarkdownFormatter struct 60 | type MarkdownFormatter struct { 61 | SimpleFormatter 62 | // RepoURL git repo remote URL address 63 | RepoURL string 64 | } 65 | 66 | // Format the log item to line 67 | func (f *MarkdownFormatter) Format(li *LogItem) (group, fmtLine string) { 68 | group = f.MatchGroup(li.Msg) 69 | 70 | if li.HashID != "" { 71 | // full url. 72 | // eg: https://github.com/inhere/kite/commit/ebd90a304755218726df4eb398fd081c08d04b9a 73 | fmtLine = fmt.Sprintf("- %s [%s](%s/commit/%s)", li.Msg, li.AbbrevID(), f.RepoURL, li.HashID) 74 | } else { 75 | fmtLine = " - " + li.Msg 76 | } 77 | 78 | if user := li.Username(); user != "" { 79 | fmtLine += " by(" + user + ")" 80 | } 81 | return 82 | } 83 | 84 | // GHReleaseFormatter struct 85 | type GHReleaseFormatter struct { 86 | MarkdownFormatter 87 | } 88 | 89 | // Format the log item to line 90 | func (f *GHReleaseFormatter) Format(li *LogItem) (group, fmtLine string) { 91 | group = f.MatchGroup(li.Msg) 92 | 93 | if li.HashID != "" { 94 | // full url. 95 | // eg: https://github.com/inhere/kite/commit/ebd90a304755218726df4eb398fd081c08d04b9a 96 | fmtLine = fmt.Sprintf("- %s %s/commit/%s", li.Msg, f.RepoURL, li.HashID) 97 | } else { 98 | fmtLine = " - " + li.Msg 99 | } 100 | 101 | if user := li.Username(); user != "" { 102 | fmtLine += " by(@" + user + ")" 103 | } 104 | return 105 | } 106 | -------------------------------------------------------------------------------- /chlog/group_match.go: -------------------------------------------------------------------------------- 1 | package chlog 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gookit/goutil/strutil" 7 | ) 8 | 9 | // DefaultGroup name 10 | var DefaultGroup = "Other" 11 | 12 | // DefaultMatcher for match group name. 13 | var DefaultMatcher = NewDefaultMatcher() 14 | 15 | // Rule struct 16 | type Rule struct { 17 | // Name for group 18 | Name string `json:"name" yaml:"name"` 19 | // StartWiths message start withs string. 20 | StartWiths []string `json:"start_withs" yaml:"start_withs"` 21 | // Contains message should contain there are strings. 22 | Contains []string `json:"contains" yaml:"contains"` 23 | } 24 | 25 | // RuleMatcher struct 26 | type RuleMatcher struct { 27 | // Names define group names and sort 28 | Names []string `json:"names" yaml:"names"` 29 | Rules []Rule `json:"rules" yaml:"rules"` 30 | } 31 | 32 | // Match group name from log message. 33 | func (m RuleMatcher) Match(msg string) string { 34 | // remove prefix like ":sparkles:" 35 | // eg ":sparkles: feat(dump): some message ..." 36 | if strings.IndexByte(msg, ':') == 0 { 37 | end := strings.IndexByte(msg[1:], ':') 38 | if end > 1 { 39 | msg = strings.TrimSpace(msg[end+2:]) 40 | } 41 | } 42 | 43 | for _, rule := range m.Rules { 44 | if len(rule.StartWiths) > 0 && strutil.HasOnePrefix(msg, rule.StartWiths) { 45 | return rule.Name 46 | } 47 | } 48 | 49 | for _, rule := range m.Rules { 50 | if len(rule.Contains) > 0 && strutil.HasOneSub(msg, rule.Contains) { 51 | return rule.Name 52 | } 53 | } 54 | 55 | return DefaultGroup 56 | } 57 | 58 | // NewDefaultMatcher instance 59 | func NewDefaultMatcher() *RuleMatcher { 60 | return &RuleMatcher{ 61 | Names: []string{"Feature", "Refactor", "Update", "Fixed", DefaultGroup}, 62 | Rules: []Rule{ 63 | { 64 | Name: "Feature", 65 | StartWiths: []string{"feat", "new", "add"}, 66 | Contains: []string{"feat:", "feat("}, 67 | }, 68 | { 69 | Name: "Refactor", 70 | StartWiths: []string{"break", "refactor"}, 71 | Contains: []string{"refactor:"}, 72 | }, 73 | { 74 | Name: "Update", 75 | StartWiths: []string{"up:", "up(", "update"}, 76 | Contains: []string{"up:", "update:"}, 77 | }, 78 | { 79 | Name: "Fixed", 80 | StartWiths: []string{"bug", "close", "fix"}, 81 | Contains: []string{"fix:", "bug:"}, 82 | }, 83 | }, 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /chlog/group_match_test.go: -------------------------------------------------------------------------------- 1 | package chlog_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gookit/gitw/chlog" 7 | "github.com/gookit/goutil/testutil/assert" 8 | ) 9 | 10 | func TestRuleMatcher_Match(t *testing.T) { 11 | line := ":sparkles: feat(dump): dump 支持 []byte 作为字符串打印和新增更多新选项" 12 | m := chlog.DefaultMatcher.Match(line) 13 | assert.Equal(t, "Feature", m) 14 | 15 | line = ":necktie: up(str): 更新字节工具方法并添加新的哈希工具方法" 16 | m = chlog.DefaultMatcher.Match(line) 17 | assert.Equal(t, "Update", m) 18 | } 19 | -------------------------------------------------------------------------------- /chlog/parser.go: -------------------------------------------------------------------------------- 1 | package chlog 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gookit/goutil" 7 | ) 8 | 9 | // Sep consts for parse git log 10 | const Sep = " | " 11 | 12 | // see https://devhints.io/git-log-format 13 | // see https://git-scm.com/docs/pretty-formats 14 | const ( 15 | // LogFmtHs - %n new line 16 | // id, msg 17 | LogFmtHs = "%H | %s" 18 | // LogFmtHsa id, msg, author 19 | LogFmtHsa = "%H | %s | %an" 20 | // LogFmtHsc id, msg, committer 21 | LogFmtHsc = "%H | %s | %cn" 22 | // LogFmtHsd id, msg, author date 23 | LogFmtHsd = "%H | %s | %ai" 24 | // LogFmtHsd1 id, msg, commit date 25 | LogFmtHsd1 = "%H | %s | %ci" 26 | ) 27 | 28 | // LineParser interface define 29 | type LineParser interface { 30 | Parse(line string, c *Changelog) *LogItem 31 | } 32 | 33 | // LineParseFunc func define 34 | type LineParseFunc func(line string, c *Changelog) *LogItem 35 | 36 | // Parse log line to log item 37 | func (f LineParseFunc) Parse(line string, c *Changelog) *LogItem { 38 | return f(line, c) 39 | } 40 | 41 | // BuiltInParser struct 42 | var BuiltInParser = LineParseFunc(func(line string, c *Changelog) *LogItem { 43 | li := &LogItem{} 44 | switch c.cfg.LogFormat { 45 | case LogFmtHs: 46 | ss := strings.SplitN(line, Sep, 2) 47 | if len(ss) < 2 { 48 | return nil 49 | } 50 | 51 | li.HashID, li.Msg = ss[0], ss[1] 52 | case LogFmtHsa: 53 | ss := strings.SplitN(line, Sep, 3) 54 | if len(ss) < 3 { 55 | return nil 56 | } 57 | 58 | li.HashID, li.Msg, li.Author = ss[0], ss[1], ss[2] 59 | case LogFmtHsc: 60 | ss := strings.SplitN(line, Sep, 3) 61 | if len(ss) < 3 { 62 | return nil 63 | } 64 | 65 | li.HashID, li.Msg, li.Committer = ss[0], ss[1], ss[2] 66 | case LogFmtHsd, LogFmtHsd1: 67 | ss := strings.SplitN(line, Sep, 3) 68 | if len(ss) < 3 { 69 | return nil 70 | } 71 | 72 | li.HashID, li.Msg, li.Date = ss[0], ss[1], ss[2] 73 | default: 74 | goutil.Panicf("unsupported log format '%s'", c.cfg.LogFormat) 75 | } 76 | 77 | return li 78 | }) 79 | -------------------------------------------------------------------------------- /cmd/chlog/go.mod: -------------------------------------------------------------------------------- 1 | module chlog 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/goccy/go-yaml v1.12.0 7 | github.com/gookit/color v1.5.4 8 | github.com/gookit/gitw v1.0.0 9 | github.com/gookit/goutil v0.6.16 10 | ) 11 | 12 | require ( 13 | github.com/fatih/color v1.17.0 // indirect 14 | github.com/gookit/gsr v0.1.0 // indirect 15 | github.com/gookit/slog v0.5.6 // indirect 16 | github.com/mattn/go-colorable v0.1.13 // indirect 17 | github.com/mattn/go-isatty v0.0.20 // indirect 18 | github.com/valyala/bytebufferpool v1.0.0 // indirect 19 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 20 | golang.org/x/sync v0.7.0 // indirect 21 | golang.org/x/sys v0.22.0 // indirect 22 | golang.org/x/term v0.22.0 // indirect 23 | golang.org/x/text v0.16.0 // indirect 24 | golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect 25 | ) 26 | 27 | replace github.com/gookit/gitw => ../../ 28 | -------------------------------------------------------------------------------- /cmd/chlog/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= 4 | github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= 5 | github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= 6 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 7 | github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= 8 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 9 | github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= 10 | github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= 11 | github.com/goccy/go-yaml v1.12.0 h1:/1WHjnMsI1dlIBQutrvSMGZRQufVO3asrHfTwfACoPM= 12 | github.com/goccy/go-yaml v1.12.0/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= 13 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 14 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 15 | github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= 16 | github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= 17 | github.com/gookit/goutil v0.6.16 h1:9fRMCF4X9abdRD5+2HhBS/GwafjBlTUBjRtA5dgkvuw= 18 | github.com/gookit/goutil v0.6.16/go.mod h1:op2q8AoPDFSiY2+qkHxcBWQMYxOLQ1GbLXqe7vrwscI= 19 | github.com/gookit/gsr v0.1.0 h1:0gadWaYGU4phMs0bma38t+Do5OZowRMEVlHv31p0Zig= 20 | github.com/gookit/gsr v0.1.0/go.mod h1:7wv4Y4WCnil8+DlDYHBjidzrEzfHhXEoFjEA0pPPWpI= 21 | github.com/gookit/slog v0.5.6 h1:fmh+7bfOK8CjidMCwE+M3S8G766oHJpT/1qdmXGALCI= 22 | github.com/gookit/slog v0.5.6/go.mod h1:RfIwzoaQ8wZbKdcqG7+3EzbkMqcp2TUn3mcaSZAw2EQ= 23 | github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= 24 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 25 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 26 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 27 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 28 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 29 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 33 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 34 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 35 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 36 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 37 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 38 | golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= 39 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 40 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 41 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 42 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 43 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 44 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 45 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 46 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 47 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 48 | golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= 49 | golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= 50 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 51 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 52 | golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 h1:LLhsEBxRTBLuKlQxFBYUOU8xyFgXv6cOTp2HASDlsDk= 53 | golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 54 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 55 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 56 | -------------------------------------------------------------------------------- /cmd/chlog/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/goccy/go-yaml" 9 | "github.com/gookit/color" 10 | "github.com/gookit/gitw" 11 | "github.com/gookit/gitw/chlog" 12 | "github.com/gookit/goutil/cflag" 13 | "github.com/gookit/goutil/cliutil" 14 | "github.com/gookit/goutil/dump" 15 | "github.com/gookit/goutil/errorx" 16 | "github.com/gookit/goutil/fsutil" 17 | "github.com/gookit/goutil/maputil" 18 | "github.com/gookit/goutil/strutil" 19 | ) 20 | 21 | // Version number 22 | var Version = "1.0.2" 23 | 24 | var opts = struct { 25 | verbose bool 26 | // with git merges log 27 | withMerges bool 28 | 29 | workdir string 30 | excludes string 31 | configFile string 32 | 33 | outputFile string 34 | sha1, sha2 string 35 | 36 | style string 37 | tagType int 38 | }{} 39 | 40 | var cfg = chlog.NewDefaultConfig() 41 | var repo = gitw.NewRepo("./") 42 | var cmd = cflag.New(func(c *cflag.CFlags) { 43 | c.Version = Version 44 | c.Desc = "Quick generate change log from git logs" 45 | }) 46 | 47 | // quick run: 48 | // 49 | // go run ./cmd/chlog 50 | // go run ./cmd/chlog -h 51 | // 52 | // install to GOPATH/bin: 53 | // 54 | // go install ./cmd/chlog 55 | func main() { 56 | configCmd() 57 | 58 | cmd.MustParse(nil) 59 | } 60 | 61 | func configCmd() { 62 | cmd.BoolVar(&opts.verbose, "verbose", false, "show more information;;v") 63 | cmd.BoolVar(&opts.withMerges, "with-merge", false, "collect git merge commits") 64 | cmd.StringVar(&opts.workdir, "workdir", "", "workdir for run, default is current workdir") 65 | cmd.StringVar(&opts.configFile, "config", "", "the YAML config file for generate changelog;;c") 66 | cmd.StringVar(&opts.outputFile, "output", "stdout", "the output file for generated changelog;;o") 67 | cmd.StringVar(&opts.excludes, "exclude", "", "exclude commit by keywords, multi split by comma") 68 | cmd.StringVar(&opts.style, "style", "", "the output contents format style\nallow: simple, markdown(mkdown,md), ghr(gh-release.gh);;s") 69 | cmd.IntVar(&opts.tagType, "tag-type", 0, `get git tag name by tag type. 70 | Allowed: 71 | 0 ref-name sort(default) 72 | 1 creator date sort 73 | 2 describe command;;t`) 74 | 75 | cmd.AddArg("sha1", "The old git sha version. allow: tag name, commit id", true, nil) 76 | cmd.AddArg("sha2", "The new git sha version. allow: tag name, commit id", false, "HEAD") 77 | 78 | cmd.Func = handle 79 | cmd.Example = ` 80 | {{cmd}} v0.1.0 HEAD 81 | {{cmd}} prev last 82 | {{cmd}} prev...last 83 | {{cmd}} --exclude 'action tests,script error' prev last 84 | {{cmd}} -c .github/changelog.yml last HEAD 85 | {{cmd}} -c .github/changelog.yml -o changelog.md last HEAD 86 | ` 87 | } 88 | 89 | func checkInput(c *cflag.CFlags) error { 90 | opts.sha1 = c.Arg("sha1").String() 91 | opts.sha2 = c.Arg("sha2").String() 92 | 93 | if strings.Contains(opts.sha1, "...") { 94 | opts.sha1, opts.sha2 = strutil.MustCut(opts.sha1, "...") 95 | } 96 | 97 | // check again 98 | if opts.sha2 == "" { 99 | return errorx.Rawf("arguments: sha1, sha2 both is required") 100 | } 101 | 102 | if opts.workdir != "" { 103 | cliutil.Infoln("try change workdir to", opts.workdir) 104 | return os.Chdir(opts.workdir) 105 | } 106 | 107 | return nil 108 | } 109 | 110 | func handle(c *cflag.CFlags) error { 111 | if err := checkInput(c); err != nil { 112 | return err 113 | } 114 | 115 | // load config 116 | loadConfig() 117 | 118 | // with some settings ... 119 | if len(opts.excludes) > 0 { 120 | cfg.Filters = append(cfg.Filters, maputil.Data{ 121 | "name": chlog.FilterKeywords, 122 | "keywords": opts.excludes, 123 | "exclude": "true", 124 | }) 125 | } 126 | 127 | // create 128 | cl := chlog.NewWithConfig(cfg) 129 | 130 | // generate 131 | err := generate(cl) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | // dump change logs to file 137 | outputTo(cl, opts.outputFile) 138 | return nil 139 | } 140 | 141 | func loadConfig() { 142 | yml := fsutil.ReadExistFile(opts.configFile) 143 | if len(yml) > 0 { 144 | if err := yaml.Unmarshal(yml, cfg); err != nil { 145 | panic(err) 146 | } 147 | } 148 | 149 | if cfg.RepoURL == "" { 150 | cfg.RepoURL = repo.DefaultRemoteInfo().URLOfHTTPS() 151 | } 152 | 153 | if opts.style != "" { 154 | cfg.Style = opts.style 155 | } 156 | 157 | if opts.verbose { 158 | cfg.Verbose = true 159 | cliutil.Cyanln("Changelog Config:") 160 | dump.NoLoc(cfg) 161 | fmt.Println() 162 | } 163 | } 164 | 165 | func generate(cl *chlog.Changelog) error { 166 | // fetch git logs 167 | var gitArgs []string 168 | if !opts.withMerges { 169 | gitArgs = append(gitArgs, "--no-merges") 170 | } 171 | 172 | sha1 := repo.AutoMatchTagByType(opts.sha1, opts.tagType) 173 | sha2 := repo.AutoMatchTagByType(opts.sha2, opts.tagType) 174 | cliutil.Infof("Generate changelog: %s to %s\n", sha1, sha2) 175 | 176 | cl.FetchGitLog(sha1, sha2, gitArgs...) 177 | 178 | // do generate 179 | return cl.Generate() 180 | } 181 | 182 | func outputTo(cl *chlog.Changelog, outFile string) { 183 | if outFile == "stdout" { 184 | fmt.Println(cl.Changelog()) 185 | return 186 | } 187 | 188 | f, err := fsutil.QuickOpenFile(outFile) 189 | if err != nil { 190 | cliutil.Errorln("open the output file error:", err) 191 | return 192 | } 193 | 194 | defer f.Close() 195 | _, err = cl.WriteTo(f) 196 | if err != nil { 197 | cliutil.Errorln("write to output file error:", err) 198 | return 199 | } 200 | 201 | color.Success.Println("OK. Changelog written to:", outFile) 202 | } 203 | -------------------------------------------------------------------------------- /cmd/gmoji/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gookit/gitw/gmoji" 7 | "github.com/gookit/goutil/cflag" 8 | "github.com/gookit/goutil/cliutil" 9 | "github.com/gookit/goutil/errorx" 10 | ) 11 | 12 | var cmd = cflag.New(func(c *cflag.CFlags) { 13 | c.Version = "1.0.0" 14 | c.Desc = "quick show or render git emoji code" 15 | }) 16 | 17 | var geOpts = struct { 18 | list bool 19 | lang string 20 | render string 21 | search cflag.String 22 | }{} 23 | 24 | // quick run: 25 | // 26 | // go run ./cmd/gmoji 27 | // go run ./cmd/gmoji -h 28 | // 29 | // install to GOPATH/bin: 30 | // 31 | // go install ./cmd/gmoji 32 | func main() { 33 | cmd.Var(&geOpts.search, "search", "search emoji by keywords,multi by comma;;s") 34 | cmd.StringVar(&geOpts.render, "render", "", "want rendered text;;r") 35 | cmd.StringVar(&geOpts.lang, "lang", gmoji.LangEN, "select language for emojis;;L") 36 | cmd.BoolVar(&geOpts.list, "list", false, "list all git emojis;;ls,l") 37 | 38 | cmd.Func = execute 39 | cmd.QuickRun() 40 | } 41 | 42 | func execute(c *cflag.CFlags) error { 43 | em, err := gmoji.Emojis(geOpts.lang) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | if geOpts.list { 49 | cliutil.Warnf("All git emojis(total: %d):\n", em.Len()) 50 | fmt.Println(em.String()) 51 | return nil 52 | } 53 | 54 | if geOpts.search != "" { 55 | sub := em.Search(geOpts.search.Strings(), 10) 56 | 57 | cliutil.Warnf("Matched emojis(total: %d):\n", sub.Len()) 58 | fmt.Println(sub.String()) 59 | return nil 60 | } 61 | 62 | return errorx.Raw("TODO render ...") 63 | } 64 | -------------------------------------------------------------------------------- /cmd_std.go: -------------------------------------------------------------------------------- 1 | package gitw 2 | 3 | // debug for std 4 | var debug bool 5 | var std = newStd() 6 | 7 | // IsDebug mode 8 | func IsDebug() bool { 9 | return debug 10 | } 11 | 12 | // SetDebug mode 13 | func SetDebug(open bool) { 14 | debug = open 15 | if open { 16 | std.BeforeExec = PrintCmdline 17 | } else { 18 | std.BeforeExec = nil 19 | } 20 | } 21 | 22 | // Std instance get 23 | func Std() *GitWrap { return std } 24 | 25 | // RestStd instance 26 | func RestStd() { 27 | std = newStd() 28 | } 29 | 30 | // GlobalFlags for run git command 31 | var GlobalFlags []string 32 | 33 | func gitCmd(args ...string) *GitWrap { 34 | // with global flags 35 | return std.New(GlobalFlags...).WithArgs(args) 36 | } 37 | 38 | func cmdWithArgs(subCmd string, args ...string) *GitWrap { 39 | // with global flags 40 | return std.Cmd(subCmd, GlobalFlags...).WithArgs(args) 41 | } 42 | 43 | func newStd() *GitWrap { 44 | gw := New() 45 | 46 | // load debug setting. 47 | debug = isDebugFromEnv() 48 | if debug { 49 | gw.BeforeExec = PrintCmdline 50 | } 51 | return gw 52 | } 53 | 54 | // ------------------------------------------------- 55 | // git commands use std 56 | // ------------------------------------------------- 57 | 58 | // Branch command of git 59 | func Branch(args ...string) *GitWrap { return std.Branch(args...) } 60 | 61 | // Log command of git 62 | // 63 | // Usage: Log("-2").OutputLines() 64 | func Log(args ...string) *GitWrap { return std.Log(args...) } 65 | 66 | // RevList command of git 67 | func RevList(args ...string) *GitWrap { return std.RevList(args...) } 68 | 69 | // Remote command of git 70 | func Remote(args ...string) *GitWrap { return std.Remote(args...) } 71 | 72 | // Show command of git 73 | func Show(args ...string) *GitWrap { return std.Show(args...) } 74 | 75 | // Tag command of git 76 | // 77 | // Usage: 78 | // Tag("-l").OutputLines() 79 | func Tag(args ...string) *GitWrap { return std.Tag(args...) } 80 | -------------------------------------------------------------------------------- /cmds.go: -------------------------------------------------------------------------------- 1 | package gitw 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/gookit/goutil/errorx" 10 | "github.com/gookit/goutil/sysutil/cmdr" 11 | ) 12 | 13 | // some from: https://github.com/github/hub/blob/master/git/git.go 14 | 15 | // Version info git. 16 | func Version() (string, error) { 17 | versionCmd := gitCmd("version") 18 | output, err := versionCmd.Output() 19 | if err != nil { 20 | return "", errorx.Wrap(err, "error running git version") 21 | } 22 | 23 | return cmdr.FirstLine(output), nil 24 | } 25 | 26 | var cachedDir string 27 | 28 | // DataDir get .git dir name. eg: ".git" 29 | func DataDir() (string, error) { 30 | if cachedDir != "" { 31 | return cachedDir, nil 32 | } 33 | 34 | // git rev-parse -q --git-dir 35 | dirCmd := gitCmd("rev-parse", "-q", "--git-dir") 36 | dirCmd.Stderr = nil 37 | output, err := dirCmd.Output() 38 | if err != nil { 39 | return "", errorx.Raw("not a git repository (or any of the parent directories): .git") 40 | } 41 | 42 | var chdir string 43 | for i, flag := range GlobalFlags { 44 | if flag == "-C" { 45 | dir := GlobalFlags[i+1] 46 | if filepath.IsAbs(dir) { 47 | chdir = dir 48 | } else { 49 | chdir = filepath.Join(chdir, dir) 50 | } 51 | } 52 | } 53 | 54 | gitDir := cmdr.FirstLine(output) 55 | if !filepath.IsAbs(gitDir) { 56 | if chdir != "" { 57 | gitDir = filepath.Join(chdir, gitDir) 58 | } 59 | 60 | gitDir, err = filepath.Abs(gitDir) 61 | if err != nil { 62 | return "", err 63 | } 64 | 65 | gitDir = filepath.Clean(gitDir) 66 | } 67 | 68 | cachedDir = gitDir 69 | return gitDir, nil 70 | } 71 | 72 | // SetWorkdir for the std 73 | func SetWorkdir(dir string) { 74 | std.WithWorkDir(dir) 75 | } 76 | 77 | // Workdir git workdir name. alias of WorkdirName() 78 | func Workdir() (string, error) { 79 | return WorkdirName() 80 | } 81 | 82 | // WorkdirName get git workdir name 83 | func WorkdirName() (string, error) { 84 | // git rev-parse --show-toplevel 85 | toplevelCmd := gitCmd("rev-parse", "--show-toplevel") 86 | toplevelCmd.Stderr = nil 87 | output, err := toplevelCmd.Output() 88 | 89 | dir := cmdr.FirstLine(output) 90 | if dir == "" { 91 | return "", fmt.Errorf("unable to determine git working directory") 92 | } 93 | return dir, err 94 | } 95 | 96 | // HasFile check 97 | func HasFile(segments ...string) bool { 98 | // The blessed way to resolve paths within git dir since Git 2.5.0 99 | pathCmd := gitCmd("rev-parse", "-q", "--git-path", filepath.Join(segments...)) 100 | pathCmd.Stderr = nil 101 | if output, err := pathCmd.Output(); err == nil { 102 | if lines := cmdr.OutputLines(output); len(lines) == 1 { 103 | if _, err := os.Stat(lines[0]); err == nil { 104 | return true 105 | } 106 | } 107 | } 108 | 109 | // Fallback for older git versions 110 | dir, err := DataDir() 111 | if err != nil { 112 | return false 113 | } 114 | 115 | s := []string{dir} 116 | s = append(s, segments...) 117 | path := filepath.Join(s...) 118 | if _, err := os.Stat(path); err == nil { 119 | return true 120 | } 121 | 122 | return false 123 | } 124 | 125 | // Head read current branch name. return like: "refs/heads/main" 126 | func Head() (string, error) { 127 | // git symbolic-ref HEAD 128 | return SymbolicRef("HEAD") 129 | } 130 | 131 | // SymbolicRef reads a branch name from a ref such as "HEAD" 132 | func SymbolicRef(ref string) (string, error) { 133 | refCmd := gitCmd("symbolic-ref", ref) 134 | refCmd.Stderr = nil 135 | output, err := refCmd.Output() 136 | return cmdr.FirstLine(output), err 137 | } 138 | 139 | // SymbolicFullName reads a branch name from a ref such as "@{upstream}" 140 | func SymbolicFullName(name string) (string, error) { 141 | parseCmd := gitCmd("rev-parse", "--symbolic-full-name", name) 142 | parseCmd.Stderr = nil 143 | output, err := parseCmd.Output() 144 | if err != nil { 145 | return "", errorx.Newf("unknown revision or path not in the working tree: %s", name) 146 | } 147 | 148 | return cmdr.FirstLine(output), nil 149 | } 150 | 151 | // Ref get 152 | func Ref(ref string) (string, error) { 153 | parseCmd := gitCmd("rev-parse", "-q", ref) 154 | parseCmd.Stderr = nil 155 | output, err := parseCmd.Output() 156 | if err != nil { 157 | return "", errorx.Newf("unknown revision or path not in the working tree: %s", ref) 158 | } 159 | 160 | return cmdr.FirstLine(output), nil 161 | } 162 | 163 | // RefList for two sha 164 | func RefList(a, b string) ([]string, error) { 165 | ref := fmt.Sprintf("%s...%s", a, b) 166 | listCmd := gitCmd("rev-list", "--cherry-pick", "--right-only", "--no-merges", ref) 167 | listCmd.Stderr = nil 168 | 169 | output, err := listCmd.Output() 170 | if err != nil { 171 | return nil, errorx.Newf("can't load rev-list for %s", ref) 172 | } 173 | 174 | return cmdr.OutputLines(output), nil 175 | } 176 | 177 | // NewRange object 178 | func NewRange(a, b string) (*Range, error) { 179 | parseCmd := gitCmd("rev-parse", "-q", a, b) 180 | parseCmd.Stderr = nil 181 | output, err := parseCmd.Output() 182 | if err != nil { 183 | return nil, err 184 | } 185 | 186 | lines := cmdr.OutputLines(output) 187 | if len(lines) != 2 { 188 | return nil, fmt.Errorf("can't parse range %s..%s", a, b) 189 | } 190 | return &Range{lines[0], lines[1]}, nil 191 | } 192 | 193 | // Range struct 194 | type Range struct { 195 | A string 196 | B string 197 | } 198 | 199 | // IsIdentical check 200 | func (r *Range) IsIdentical() bool { 201 | return strings.EqualFold(r.A, r.B) 202 | } 203 | 204 | // IsAncestor check 205 | func (r *Range) IsAncestor() bool { 206 | cmd := gitCmd("merge-base", "--is-ancestor", r.A, r.B) 207 | return cmd.Success() 208 | } 209 | 210 | // CommentChar find 211 | func CommentChar(text string) (string, error) { 212 | char, err := gitConfigGet("core.commentchar") 213 | if err != nil { 214 | return "#", nil 215 | } 216 | 217 | if char == "auto" { 218 | lines := strings.Split(text, "\n") 219 | commentCharCandidates := strings.Split("#;@!$%^&|:", "") 220 | candidateLoop: 221 | for _, candidate := range commentCharCandidates { 222 | for _, line := range lines { 223 | if strings.HasPrefix(line, candidate) { 224 | continue candidateLoop 225 | } 226 | } 227 | return candidate, nil 228 | } 229 | return "", errorx.Raw("unable to select a comment character that is not used in the current message") 230 | } 231 | 232 | return char, nil 233 | } 234 | 235 | // ShowDiff git log diff by a commit sha 236 | func ShowDiff(sha string) (string, error) { 237 | gw := gitCmd("-c", "log.showSignature=false") 238 | gw.WithArg("show", "-s", "--format=%s%n%+b", sha) 239 | 240 | output, err := gw.Output() 241 | return strings.TrimSpace(output), err 242 | } 243 | 244 | // ShowLogs show git log between sha1 to sha2 245 | // 246 | // Usage: 247 | // 248 | // gitw.ShowLogs("v1.0.2", "v1.0.3") 249 | // gitw.ShowLogs("commit id 1", "commit id 2") 250 | func ShowLogs(sha1, sha2 string) (string, error) { 251 | execCmd := gitCmd("-c", "log.showSignature=false", "log", "--no-color") 252 | // execCmd.WithArg("--format='%h (%aN, %ar)%n%w(78,3,3)%s%n%+b'") 253 | execCmd.WithArg("--format=%h (%aN, %ar)%n%w(78,3,3)%s%n%+b") 254 | execCmd.WithArg("--cherry") 255 | 256 | // shaRange := fmt.Sprintf("%s...%s", sha1, sha2) 257 | shaRange := strings.Join([]string{sha1, "...", sha2}, "") 258 | execCmd.WithArg(shaRange) 259 | 260 | outputs, err := execCmd.Output() 261 | if err != nil { 262 | return "", fmt.Errorf("can't load git log for %s..%s", sha1, sha2) 263 | } 264 | 265 | return outputs, nil 266 | } 267 | 268 | // Tags list 269 | // 270 | // something: 271 | // 272 | // `git tag -l` == `git tag --format '%(refname:strip=2)'` 273 | // 274 | // more e.g: 275 | // 276 | // // refname - sorts in a lexicographic order 277 | // // version:refname or v:refname - this sorts based on version 278 | // git tag --sort=-version:refname 279 | // git tag -l --sort version:refname 280 | // git tag --format '%(refname:strip=2)' --sort=-taggerdate 281 | // git tag --format '%(refname:strip=2) %(objectname)' --sort=-taggerdate 282 | // git log --tags --simplify-by-decoration --pretty="format:%d - %cr" 283 | func Tags(args ...string) ([]string, error) { 284 | if len(args) > 0 { 285 | args1 := make([]string, len(args)+1) 286 | args1[0] = "-l" 287 | 288 | copy(args1[1:], args) 289 | return Tag(args1...).OutputLines() 290 | } 291 | 292 | return Tag("-l").OutputLines() 293 | } 294 | 295 | // Branches list 296 | func Branches() ([]string, error) { 297 | branchesCmd := gitCmd("branch", "--list") 298 | output, err := branchesCmd.Output() 299 | if err != nil { 300 | return nil, err 301 | } 302 | 303 | var branches []string 304 | for _, branch := range cmdr.OutputLines(output) { 305 | branches = append(branches, branch[2:]) 306 | } 307 | return branches, nil 308 | } 309 | 310 | // Remotes list 311 | func Remotes() ([]string, error) { 312 | remoteCmd := gitCmd("remote", "-v") 313 | remoteCmd.Stderr = nil 314 | output, err := remoteCmd.Output() 315 | if err != nil { 316 | return nil, err 317 | } 318 | 319 | return cmdr.OutputLines(output), nil 320 | } 321 | 322 | // ------------------------------------------------- 323 | // git var 324 | // ------------------------------------------------- 325 | 326 | // Var get by git var. 327 | // 328 | // Example 329 | // 330 | // all: git var -l 331 | // one: git var GIT_EDITOR 332 | func Var(name string) string { 333 | val, err := New("var", name).Output() 334 | if err != nil { 335 | return "" 336 | } 337 | return val 338 | } 339 | 340 | // AllVars get all git vars 341 | func AllVars() string { 342 | return Var("-l") 343 | } 344 | 345 | // ------------------------------------------------- 346 | // git config 347 | // ------------------------------------------------- 348 | 349 | // Config get git config by name 350 | func Config(name string) string { 351 | val, err := gitConfigGet(name) 352 | if err != nil { 353 | return "" 354 | } 355 | return val 356 | } 357 | 358 | // ConfigAll get 359 | func ConfigAll(name string) ([]string, error) { 360 | mode := "--get-all" 361 | if strings.Contains(name, "*") { 362 | mode = "--get-regexp" 363 | } 364 | 365 | // configCmd := gitCmd(gitConfigCommand([]string{mode, name})...) 366 | configCmd := cmdWithArgs("config", mode, name) 367 | output, err := configCmd.Output() 368 | if err != nil { 369 | return nil, errorx.Newf("unknown config %s", name) 370 | } 371 | return cmdr.OutputLines(output), nil 372 | } 373 | 374 | // GlobalConfig get git global config by name 375 | func GlobalConfig(name string) (string, error) { 376 | return gitConfigGet("--global", name) 377 | } 378 | 379 | // SetGlobalConfig by name 380 | func SetGlobalConfig(name, value string) error { 381 | _, err := gitConfig("--global", name, value) 382 | return err 383 | } 384 | 385 | func gitConfigGet(args ...string) (string, error) { 386 | configCmd := gitCmd(gitConfigCommand(args)...) 387 | output, err := configCmd.Output() 388 | if err != nil { 389 | return "", fmt.Errorf("unknown config %s", args[len(args)-1]) 390 | } 391 | 392 | return cmdr.FirstLine(output), nil 393 | } 394 | 395 | func gitConfig(args ...string) ([]string, error) { 396 | configCmd := gitCmd(gitConfigCommand(args)...) 397 | output, err := configCmd.Output() 398 | return cmdr.OutputLines(output), err 399 | } 400 | 401 | func gitConfigCommand(args []string) []string { 402 | cmd := []string{"config"} 403 | return append(cmd, args...) 404 | } 405 | 406 | // Alias find 407 | func Alias(name string) string { 408 | return Config("alias." + name) 409 | } 410 | 411 | // Run command with args 412 | func Run(args ...string) error { 413 | return gitCmd(args...).Run() 414 | } 415 | 416 | // Spawn run command with args 417 | func Spawn(args ...string) error { 418 | return gitCmd(args...).Spawn() 419 | } 420 | 421 | // Quiet run 422 | func Quiet(args ...string) bool { 423 | return gitCmd(args...).Success() 424 | } 425 | 426 | // IsGitCmd check 427 | func IsGitCmd(command string) bool { 428 | return IsGitCommand(command) 429 | } 430 | 431 | // IsGitCommand check 432 | func IsGitCommand(command string) bool { 433 | helpCmd := gitCmd("help", "--no-verbose", "-a") 434 | helpCmd.Stderr = nil 435 | // run 436 | cmdOutput, err := helpCmd.Output() 437 | if err != nil { 438 | // support git versions that don't recognize --no-verbose 439 | helpCommand := gitCmd("help", "-a") 440 | cmdOutput, err = helpCommand.Output() 441 | } 442 | if err != nil { 443 | return false 444 | } 445 | 446 | for _, helpCmdOutputLine := range cmdr.OutputLines(cmdOutput) { 447 | if strings.HasPrefix(helpCmdOutputLine, " ") { 448 | for _, gitCommand := range strings.Split(helpCmdOutputLine, " ") { 449 | if gitCommand == command { 450 | return true 451 | } 452 | } 453 | } 454 | } 455 | return false 456 | } 457 | -------------------------------------------------------------------------------- /cmds_test.go: -------------------------------------------------------------------------------- 1 | package gitw_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gookit/gitw" 7 | "github.com/gookit/goutil/dump" 8 | "github.com/gookit/goutil/testutil/assert" 9 | ) 10 | 11 | func TestTags(t *testing.T) { 12 | ts, err := gitw.Tags() 13 | assert.NoErr(t, err) 14 | dump.P(ts) 15 | 16 | ts, err = gitw.Tags("-n", "--sort=-version:refname") 17 | assert.NoErr(t, err) 18 | dump.P(ts) 19 | } 20 | -------------------------------------------------------------------------------- /gitutil/gitutil.go: -------------------------------------------------------------------------------- 1 | package gitutil 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/gookit/goutil/errorx" 8 | "github.com/gookit/goutil/strutil" 9 | ) 10 | 11 | // SplitPath split path to group and name. 12 | func SplitPath(repoPath string) (group, name string, err error) { 13 | group, name = strutil.TrimCut(repoPath, "/") 14 | 15 | if strutil.HasEmpty(group, name) { 16 | err = errorx.Raw("invalid git repo path, must be as GROUP/NAME") 17 | } 18 | return 19 | } 20 | 21 | var repoPathReg = regexp.MustCompile(`^[\w-]+/[\w-]+$`) 22 | 23 | // IsRepoPath string. should match GROUP/NAME 24 | func IsRepoPath(path string) bool { 25 | return repoPathReg.MatchString(path) 26 | } 27 | 28 | // ParseCommitTopic for git commit message 29 | func ParseCommitTopic(msg string) []string { 30 | return nil // TODO 31 | } 32 | 33 | // ResolveGhURL string 34 | func ResolveGhURL(s string) (string, bool) { 35 | if strings.HasPrefix(s, githubHost) { 36 | return "https://" + s, true 37 | } 38 | return s, false 39 | } 40 | 41 | // IsFullURL quick and simple check input is ssh/http URL 42 | func IsFullURL(s string) bool { 43 | if IsHTTPProto(s) { 44 | return true 45 | } 46 | return IsSSHProto(s) 47 | } 48 | 49 | // IsHTTPProto check input is HTTP URL 50 | func IsHTTPProto(s string) bool { 51 | return strings.HasPrefix(s, "http:") || strings.HasPrefix(s, "https:") 52 | } 53 | 54 | // IsSSHProto check input is ssh URL 55 | func IsSSHProto(s string) bool { 56 | return strings.HasPrefix(s, "ssh:") || strings.HasPrefix(s, "git@") 57 | } 58 | 59 | // regex which validates that the git branch name is correct 60 | var brNameReg = regexp.MustCompile(`^[a-zA-Z0-9]+([/_-][a-zA-Z0-9]+)*$`) 61 | 62 | // IsBranchName validate branch name 63 | func IsBranchName(name string) bool { 64 | return brNameReg.MatchString(name) 65 | } 66 | 67 | // FormatVersion string. eg: v1.2.0 -> 1.2.0 68 | func FormatVersion(ver string) (string, bool) { 69 | ver = strings.TrimLeft(ver, "vV") 70 | if strutil.IsVersion(ver) { 71 | return ver, true 72 | } 73 | return "", false 74 | } 75 | 76 | // IsValidVersion check 77 | func IsValidVersion(ver string) bool { 78 | ver = strings.TrimLeft(ver, "vV") 79 | return strutil.IsVersion(ver) 80 | } 81 | 82 | // NextVersion build. eg: v1.2.0 -> v1.2.1 83 | func NextVersion(ver string) string { 84 | if len(ver) == 0 { 85 | return "v0.0.1" 86 | } 87 | 88 | ver = strings.TrimLeft(ver, "vV") 89 | nodes := strings.Split(ver, ".") 90 | if len(nodes) == 1 { 91 | return ver + ".0.1" 92 | } 93 | 94 | for i := len(nodes) - 1; i > 0; i-- { 95 | num, err := strutil.ToInt(nodes[i]) 96 | if err != nil { 97 | continue 98 | } 99 | nodes[i] = strutil.SafeString(num + 1) 100 | break 101 | } 102 | 103 | return strings.Join(nodes, ".") 104 | } 105 | -------------------------------------------------------------------------------- /gitutil/gitutil_test.go: -------------------------------------------------------------------------------- 1 | package gitutil_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gookit/gitw/gitutil" 7 | "github.com/gookit/goutil/testutil/assert" 8 | ) 9 | 10 | func TestSplitPath(t *testing.T) { 11 | type args struct { 12 | repoPath string 13 | } 14 | tests := []struct { 15 | args args 16 | wantGroup string 17 | wantName string 18 | wantErr bool 19 | }{ 20 | { 21 | args: args{"my/repo"}, 22 | wantGroup: "my", 23 | wantName: "repo", 24 | }, 25 | { 26 | args: args{"my/repo-01"}, 27 | wantGroup: "my", 28 | wantName: "repo-01", 29 | }, 30 | } 31 | for _, tt := range tests { 32 | gotGroup, gotName, err := gitutil.SplitPath(tt.args.repoPath) 33 | 34 | assert.Eq(t, tt.wantGroup, gotGroup) 35 | assert.Eq(t, tt.wantName, gotName) 36 | 37 | if tt.wantErr { 38 | assert.Err(t, err) 39 | } else { 40 | assert.Nil(t, err) 41 | } 42 | } 43 | } 44 | 45 | func TestIsRepoPath(t *testing.T) { 46 | tests := []struct { 47 | path string 48 | want bool 49 | }{ 50 | {"my/repo", true}, 51 | {"my/repo-01", true}, 52 | {"my/repo/sub01", false}, 53 | {"my-repo-01", false}, 54 | } 55 | 56 | for _, tt := range tests { 57 | assert.Eq(t, tt.want, gitutil.IsRepoPath(tt.path)) 58 | } 59 | } 60 | 61 | func TestIsFullURL(t *testing.T) { 62 | tests := []struct { 63 | args string 64 | want bool 65 | }{ 66 | {"inhere/gitw", false}, 67 | {"github.com/inhere/gitw", false}, 68 | {"https://github.com/inhere/gitw", true}, 69 | {"http://github.com/inhere/gitw", true}, 70 | {"git@github.com:inhere/gitw", true}, 71 | {"ssh://git@github.com:inhere/gitw", true}, 72 | } 73 | 74 | for _, tt := range tests { 75 | assert.Eq(t, tt.want, gitutil.IsFullURL(tt.args)) 76 | } 77 | } 78 | 79 | func TestIsBranchName(t *testing.T) { 80 | tests := []struct { 81 | name string 82 | want bool 83 | }{ 84 | {"master", true}, 85 | {"dev", true}, 86 | {"dev-01", true}, 87 | {"dev_01", true}, 88 | {"dev/01", true}, 89 | {"dev-01/02", true}, 90 | {"dev_01/02", true}, 91 | {"dev/01-02", true}, 92 | {"dev/01_02", true}, 93 | {"dev/01-02/03", true}, 94 | {"-master", false}, 95 | {"dev-", false}, 96 | {"start:fea-12", false}, 97 | } 98 | 99 | for _, tt := range tests { 100 | assert.Eq(t, tt.want, gitutil.IsBranchName(tt.name)) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /gitutil/ssh_config.go: -------------------------------------------------------------------------------- 1 | package gitutil 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/gookit/goutil/sysutil" 11 | ) 12 | 13 | const ( 14 | hostReStr = "(?i)^[ \t]*(host|hostname)[ \t]+(.+)$" 15 | ) 16 | 17 | // SSHConfig map 18 | type SSHConfig map[string]string 19 | 20 | func newSSHConfigReader() *SSHConfigReader { 21 | configFiles := []string{ 22 | "/etc/ssh_config", 23 | "/etc/ssh/ssh_config", 24 | } 25 | if hdir := sysutil.HomeDir(); hdir != "" { 26 | userConfig := filepath.Join(hdir, ".ssh", "config") 27 | configFiles = append([]string{userConfig}, configFiles...) 28 | } 29 | 30 | return &SSHConfigReader{ 31 | Files: configFiles, 32 | } 33 | } 34 | 35 | // SSHConfigReader struct 36 | type SSHConfigReader struct { 37 | Files []string 38 | } 39 | 40 | // Read config from files 41 | func (r *SSHConfigReader) Read() SSHConfig { 42 | config := make(SSHConfig) 43 | hostRe := regexp.MustCompile(hostReStr) 44 | 45 | for _, filename := range r.Files { 46 | r.readFile(config, hostRe, filename) 47 | } 48 | 49 | return config 50 | } 51 | 52 | func (r *SSHConfigReader) readFile(c SSHConfig, re *regexp.Regexp, f string) error { 53 | file, err := os.Open(f) 54 | if err != nil { 55 | return err 56 | } 57 | defer file.Close() 58 | 59 | hosts := []string{"*"} 60 | scanner := bufio.NewScanner(file) 61 | for scanner.Scan() { 62 | line := scanner.Text() 63 | match := re.FindStringSubmatch(line) 64 | if match == nil { 65 | continue 66 | } 67 | 68 | names := strings.Fields(match[2]) 69 | if strings.EqualFold(match[1], "host") { 70 | hosts = names 71 | } else { 72 | for _, host := range hosts { 73 | for _, name := range names { 74 | c[host] = expandTokens(name, host) 75 | } 76 | } 77 | } 78 | } 79 | 80 | return scanner.Err() 81 | } 82 | 83 | func expandTokens(text, host string) string { 84 | re := regexp.MustCompile(`%[%h]`) 85 | return re.ReplaceAllStringFunc(text, func(match string) string { 86 | switch match { 87 | case "%h": 88 | return host 89 | case "%%": 90 | return "%" 91 | } 92 | return "" 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /gitutil/url_parser.go: -------------------------------------------------------------------------------- 1 | package gitutil 2 | 3 | import ( 4 | "net/url" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | cachedSSHCfg SSHConfig 11 | protocolReg = regexp.MustCompile("^[a-zA-Z_+-]+://") 12 | ) 13 | 14 | const githubHost = "github.com" 15 | 16 | // URLParser struct 17 | type URLParser struct { 18 | SSHConfig SSHConfig 19 | } 20 | 21 | // Parse parse raw url 22 | func (p *URLParser) Parse(rawURL string) (u *url.URL, err error) { 23 | if !protocolReg.MatchString(rawURL) && 24 | strings.Contains(rawURL, ":") && 25 | // not a Windows path 26 | !strings.Contains(rawURL, "\\") { 27 | rawURL = "ssh://" + strings.Replace(rawURL, ":", "/", 1) 28 | } 29 | 30 | u, err = url.Parse(rawURL) 31 | if err != nil { 32 | return 33 | } 34 | 35 | if u.Scheme == "git+ssh" { 36 | u.Scheme = "ssh" 37 | } 38 | 39 | if u.Scheme != "ssh" { 40 | return 41 | } 42 | 43 | if strings.HasPrefix(u.Path, "//") { 44 | u.Path = strings.TrimPrefix(u.Path, "/") 45 | } 46 | 47 | if idx := strings.Index(u.Host, ":"); idx >= 0 { 48 | u.Host = u.Host[0:idx] 49 | } 50 | 51 | sshHost := p.SSHConfig[u.Host] 52 | // ignore replacing host that fixes for limited network 53 | // https://help.github.com/articles/using-ssh-over-the-https-port 54 | ignoredHost := u.Host == githubHost && sshHost == "ssh.github.com" 55 | if !ignoredHost && sshHost != "" { 56 | u.Host = sshHost 57 | } 58 | 59 | return 60 | } 61 | 62 | // ParseURL parse raw url 63 | func ParseURL(rawURL string) (u *url.URL, err error) { 64 | if cachedSSHCfg == nil { 65 | cachedSSHCfg = newSSHConfigReader().Read() 66 | } 67 | 68 | p := &URLParser{cachedSSHCfg} 69 | return p.Parse(rawURL) 70 | } 71 | -------------------------------------------------------------------------------- /gitw.go: -------------------------------------------------------------------------------- 1 | // Package gitw git command wrapper, git changelog, repo information and some git tools. 2 | package gitw 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | "syscall" 11 | 12 | "github.com/gookit/goutil/errorx" 13 | "github.com/gookit/goutil/fsutil" 14 | "github.com/gookit/goutil/sysutil/cmdr" 15 | ) 16 | 17 | // some from: https://github.com/github/hub/blob/master/cmd/cmd.go 18 | 19 | const ( 20 | // GitDir name 21 | GitDir = ".git" 22 | // HeadFile in .git/ 23 | HeadFile = "HEAD" 24 | // ConfFile in .git/ 25 | ConfFile = "config" 26 | // GitHubHost name 27 | GitHubHost = "github.com" 28 | // GitHubURL string 29 | GitHubURL = "https://github.com" 30 | // GitHubGit string 31 | GitHubGit = "git@github.com" 32 | ) 33 | 34 | // git host type 35 | const ( 36 | TypeGitHub = "github" 37 | TypeGitlab = "gitlab" 38 | TypeDefault = "git" 39 | ) 40 | 41 | const ( 42 | // DefaultBin name 43 | DefaultBin = "git" 44 | 45 | // DefaultBranchName value 46 | DefaultBranchName = "master" 47 | // DefaultRemoteName value 48 | DefaultRemoteName = "origin" 49 | ) 50 | 51 | // GitWrap is a project-wide struct that represents a command to be run in the console. 52 | type GitWrap struct { 53 | // Workdir for run git 54 | Workdir string 55 | // Bin git bin name. default is "git" 56 | Bin string 57 | // Args for run git. contains git command name. 58 | Args []string 59 | // Stdin more settings 60 | Stdin io.Reader 61 | Stdout io.Writer 62 | Stderr io.Writer 63 | 64 | // DryRun if True, not real execute command 65 | DryRun bool 66 | // BeforeExec command hook. 67 | // 68 | // Usage: gw.BeforeExec = gitw.PrintCmdline 69 | BeforeExec func(gw *GitWrap) 70 | } 71 | 72 | // New create instance with args 73 | func New(args ...string) *GitWrap { 74 | return &GitWrap{ 75 | Bin: DefaultBin, 76 | Args: args, 77 | // Stdin: os.Stdin, // not init stdin 78 | Stdout: os.Stdout, 79 | Stderr: os.Stderr, 80 | } 81 | } 82 | 83 | // Cmd create instance with git cmd and args 84 | func Cmd(cmd string, args ...string) *GitWrap { 85 | return New(cmd).WithArgs(args) 86 | } 87 | 88 | // NewWithArgs create instance with git cmd and args 89 | func NewWithArgs(cmd string, args ...string) *GitWrap { 90 | return New(cmd).WithArgs(args) 91 | } 92 | 93 | // NewWithWorkdir create instance with workdir and args 94 | func NewWithWorkdir(workdir string, args ...string) *GitWrap { 95 | return New(args...).WithWorkDir(workdir) 96 | } 97 | 98 | // New git wrap from current instance, can with args 99 | func (gw *GitWrap) New(args ...string) *GitWrap { 100 | nw := *gw 101 | nw.Args = args 102 | return &nw 103 | } 104 | 105 | // Sub new sub git cmd from current instance, can with args 106 | func (gw *GitWrap) Sub(cmd string, args ...string) *GitWrap { 107 | return gw.Cmd(cmd, args...) 108 | } 109 | 110 | // Cmd new git wrap from current instance, can with args 111 | func (gw *GitWrap) Cmd(cmd string, args ...string) *GitWrap { 112 | nw := *gw 113 | nw.Args = []string{cmd} 114 | 115 | if len(args) > 0 { 116 | nw.WithArgs(args) 117 | } 118 | return &nw 119 | } 120 | 121 | // WithFn for setting gw 122 | func (gw *GitWrap) WithFn(fn func(gw *GitWrap)) *GitWrap { 123 | fn(gw) 124 | return gw 125 | } 126 | 127 | // ------------------------------------------------- 128 | // config the git command 129 | // ------------------------------------------------- 130 | 131 | // String to command line 132 | func (gw *GitWrap) String() string { 133 | return gw.Cmdline() 134 | } 135 | 136 | // Cmdline to command line 137 | func (gw *GitWrap) Cmdline() string { 138 | b := new(strings.Builder) 139 | b.WriteString(gw.Bin) 140 | 141 | for _, a := range gw.Args { 142 | b.WriteByte(' ') 143 | if strings.ContainsRune(a, '"') { 144 | b.WriteString(fmt.Sprintf(`'%s'`, a)) 145 | } else if a == "" || strings.ContainsRune(a, '\'') || strings.ContainsRune(a, ' ') { 146 | b.WriteString(fmt.Sprintf(`"%s"`, a)) 147 | } else { 148 | b.WriteString(a) 149 | } 150 | } 151 | return b.String() 152 | } 153 | 154 | // IsGitRepo return the work dir is a git repo. 155 | func (gw *GitWrap) IsGitRepo() bool { 156 | return fsutil.IsDir(gw.GitDir()) 157 | } 158 | 159 | // GitDir return .git data dir 160 | func (gw *GitWrap) GitDir() string { 161 | gitDir := GitDir 162 | if gw.Workdir != "" { 163 | gitDir = gw.Workdir + "/" + GitDir 164 | } 165 | 166 | return gitDir 167 | } 168 | 169 | // ------------------------------------------------- 170 | // config the git command 171 | // ------------------------------------------------- 172 | 173 | // PrintCmdline on exec command 174 | func (gw *GitWrap) PrintCmdline() *GitWrap { 175 | gw.BeforeExec = PrintCmdline 176 | return gw 177 | } 178 | 179 | // WithDryRun on exec command 180 | func (gw *GitWrap) WithDryRun(dryRun bool) *GitWrap { 181 | gw.DryRun = dryRun 182 | return gw 183 | } 184 | 185 | // OnBeforeExec add hook 186 | func (gw *GitWrap) OnBeforeExec(fn func(gw *GitWrap)) *GitWrap { 187 | gw.BeforeExec = fn 188 | return gw 189 | } 190 | 191 | // WithWorkDir returns the current object 192 | func (gw *GitWrap) WithWorkDir(dir string) *GitWrap { 193 | gw.Workdir = dir 194 | return gw 195 | } 196 | 197 | // WithStdin returns the current argument 198 | func (gw *GitWrap) WithStdin(in io.Reader) *GitWrap { 199 | gw.Stdin = in 200 | return gw 201 | } 202 | 203 | // WithOutput returns the current argument 204 | func (gw *GitWrap) WithOutput(out, errOut io.Writer) *GitWrap { 205 | gw.Stdout = out 206 | if errOut != nil { 207 | gw.Stderr = errOut 208 | } 209 | return gw 210 | } 211 | 212 | // WithArg add args and returns the current object. alias of the WithArg() 213 | func (gw *GitWrap) WithArg(args ...string) *GitWrap { 214 | gw.Args = append(gw.Args, args...) 215 | return gw 216 | } 217 | 218 | // AddArg add args and returns the current object 219 | func (gw *GitWrap) AddArg(args ...string) *GitWrap { 220 | return gw.WithArg(args...) 221 | } 222 | 223 | // Argf add arg and returns the current object. 224 | func (gw *GitWrap) Argf(format string, args ...interface{}) *GitWrap { 225 | gw.Args = append(gw.Args, fmt.Sprintf(format, args...)) 226 | return gw 227 | } 228 | 229 | // WithArgf add arg and returns the current object. alias of the Argf() 230 | func (gw *GitWrap) WithArgf(format string, args ...interface{}) *GitWrap { 231 | return gw.Argf(format, args...) 232 | } 233 | 234 | // ArgIf add arg and returns the current object 235 | func (gw *GitWrap) ArgIf(arg string, exprOk bool) *GitWrap { 236 | if exprOk { 237 | gw.Args = append(gw.Args, arg) 238 | } 239 | return gw 240 | } 241 | 242 | // WithArgIf add arg and returns the current object 243 | func (gw *GitWrap) WithArgIf(arg string, exprOk bool) *GitWrap { 244 | return gw.ArgIf(arg, exprOk) 245 | } 246 | 247 | // AddArgs for the git. alias of WithArgs() 248 | func (gw *GitWrap) AddArgs(args []string) *GitWrap { 249 | return gw.WithArgs(args) 250 | } 251 | 252 | // WithArgs for the git 253 | func (gw *GitWrap) WithArgs(args []string) *GitWrap { 254 | if len(args) > 0 { 255 | gw.Args = append(gw.Args, args...) 256 | } 257 | return gw 258 | } 259 | 260 | // WithArgsIf add arg and returns the current object 261 | func (gw *GitWrap) WithArgsIf(args []string, exprOk bool) *GitWrap { 262 | if exprOk && len(args) > 0 { 263 | gw.Args = append(gw.Args, args...) 264 | } 265 | return gw 266 | } 267 | 268 | // ResetArgs for git 269 | func (gw *GitWrap) ResetArgs() { 270 | gw.Args = make([]string, 0) 271 | } 272 | 273 | // ------------------------------------------------- 274 | // run git command 275 | // ------------------------------------------------- 276 | 277 | // NewExecCmd create exec.Cmd from current cmd 278 | func (gw *GitWrap) NewExecCmd() *exec.Cmd { 279 | c := exec.Command(gw.Bin, gw.Args...) 280 | c.Dir = gw.Workdir 281 | c.Stdin = gw.Stdin 282 | c.Stdout = gw.Stdout 283 | c.Stderr = gw.Stderr 284 | 285 | return c 286 | } 287 | 288 | // Success run and return whether success 289 | func (gw *GitWrap) Success() bool { 290 | if gw.BeforeExec != nil { 291 | gw.BeforeExec(gw) 292 | } 293 | if gw.DryRun { 294 | return true 295 | } 296 | 297 | return gw.NewExecCmd().Run() == nil 298 | } 299 | 300 | // SafeLines run and return output as lines 301 | func (gw *GitWrap) SafeLines() []string { 302 | ss, _ := gw.OutputLines() 303 | return ss 304 | } 305 | 306 | // OutputLines run and return output as lines 307 | func (gw *GitWrap) OutputLines() ([]string, error) { 308 | out, err := gw.Output() 309 | if err != nil { 310 | return nil, err 311 | } 312 | return cmdr.OutputLines(out), err 313 | } 314 | 315 | // SafeOutput run and return output 316 | func (gw *GitWrap) SafeOutput() string { 317 | gw.Stderr = nil // ignore stderr 318 | out, err := gw.Output() 319 | 320 | if err != nil { 321 | return "" 322 | } 323 | return out 324 | } 325 | 326 | // Output run and return output 327 | func (gw *GitWrap) Output() (string, error) { 328 | c := exec.Command(gw.Bin, gw.Args...) 329 | c.Dir = gw.Workdir 330 | c.Stderr = gw.Stderr 331 | 332 | if gw.BeforeExec != nil { 333 | gw.BeforeExec(gw) 334 | } 335 | if gw.DryRun { 336 | return "DIY-RUN: OK", nil 337 | } 338 | 339 | output, err := c.Output() 340 | return string(output), err 341 | } 342 | 343 | // CombinedOutput run and return output, will combine stderr and stdout output 344 | func (gw *GitWrap) CombinedOutput() (string, error) { 345 | c := exec.Command(gw.Bin, gw.Args...) 346 | c.Dir = gw.Workdir 347 | 348 | if gw.BeforeExec != nil { 349 | gw.BeforeExec(gw) 350 | } 351 | if gw.DryRun { 352 | return "DIY-RUN: OK", nil 353 | } 354 | 355 | output, err := c.CombinedOutput() 356 | return string(output), err 357 | } 358 | 359 | // MustRun a command. will panic on error 360 | func (gw *GitWrap) MustRun() { 361 | if err := gw.Run(); err != nil { 362 | panic(err) 363 | } 364 | } 365 | 366 | // Run runs command with `Exec` on platforms except Windows 367 | // which only supports `Spawn` 368 | func (gw *GitWrap) Run() error { 369 | if gw.BeforeExec != nil { 370 | gw.BeforeExec(gw) 371 | } 372 | if gw.DryRun { 373 | fmt.Println("DIY-RUN: OK") 374 | return nil 375 | } 376 | 377 | return gw.NewExecCmd().Run() 378 | 379 | // if envutil.IsWindows() { 380 | // return gw.Spawn() 381 | // } 382 | // return gw.Exec() 383 | } 384 | 385 | // Spawn runs command with spawn(3) 386 | func (gw *GitWrap) Spawn() error { 387 | if gw.DryRun { 388 | return nil 389 | } 390 | return gw.NewExecCmd().Run() 391 | } 392 | 393 | // Exec runs command with exec(3) 394 | // Note that Windows doesn't support exec(3): http://golang.org/src/pkg/syscall/exec_windows.go#L339 395 | func (gw *GitWrap) Exec() error { 396 | binary, err := exec.LookPath(gw.Bin) 397 | if err != nil { 398 | return &exec.Error{ 399 | Name: gw.Bin, 400 | Err: errorx.Newf("%s not found in the system", gw.Bin), 401 | } 402 | } 403 | 404 | args := []string{binary} 405 | args = append(args, gw.Args...) 406 | 407 | if gw.BeforeExec != nil { 408 | gw.BeforeExec(gw) 409 | } 410 | if gw.DryRun { 411 | fmt.Println("DIY-RUN: OK") 412 | return nil 413 | } 414 | 415 | return syscall.Exec(binary, args, os.Environ()) 416 | } 417 | 418 | // ------------------------------------------------- 419 | // commands of git 420 | // ------------------------------------------------- 421 | 422 | // Add command for git 423 | func (gw *GitWrap) Add(args ...string) *GitWrap { 424 | return gw.Cmd("add", args...) 425 | } 426 | 427 | // Annotate command for git 428 | func (gw *GitWrap) Annotate(args ...string) *GitWrap { 429 | return gw.Cmd("annotate", args...) 430 | } 431 | 432 | // Apply command for git 433 | func (gw *GitWrap) Apply(args ...string) *GitWrap { 434 | return gw.Cmd("apply", args...) 435 | } 436 | 437 | // Bisect command for git 438 | func (gw *GitWrap) Bisect(args ...string) *GitWrap { 439 | return gw.Cmd("bisect", args...) 440 | } 441 | 442 | // Blame command for git 443 | func (gw *GitWrap) Blame(args ...string) *GitWrap { 444 | return gw.Cmd("blame", args...) 445 | } 446 | 447 | // Branch command for git 448 | func (gw *GitWrap) Branch(args ...string) *GitWrap { 449 | return gw.Cmd("branch", args...) 450 | } 451 | 452 | // Checkout command for git 453 | func (gw *GitWrap) Checkout(args ...string) *GitWrap { 454 | return gw.Cmd("checkout", args...) 455 | } 456 | 457 | // CherryPick command for git 458 | func (gw *GitWrap) CherryPick(args ...string) *GitWrap { 459 | return gw.Cmd("cherry-pick", args...) 460 | } 461 | 462 | // Clean command for git 463 | func (gw *GitWrap) Clean(args ...string) *GitWrap { 464 | return gw.Cmd("clean", args...) 465 | } 466 | 467 | // Clone command for git 468 | func (gw *GitWrap) Clone(args ...string) *GitWrap { 469 | return gw.Cmd("clone", args...) 470 | } 471 | 472 | // Commit command for git 473 | func (gw *GitWrap) Commit(args ...string) *GitWrap { 474 | return gw.Cmd("commit", args...) 475 | } 476 | 477 | // Config command for git 478 | func (gw *GitWrap) Config(args ...string) *GitWrap { 479 | return gw.Cmd("config", args...) 480 | } 481 | 482 | // Describe command for git 483 | func (gw *GitWrap) Describe(args ...string) *GitWrap { 484 | return gw.Cmd("describe", args...) 485 | } 486 | 487 | // Diff command for git 488 | func (gw *GitWrap) Diff(args ...string) *GitWrap { 489 | return gw.Cmd("diff", args...) 490 | } 491 | 492 | // Fetch command for git 493 | func (gw *GitWrap) Fetch(args ...string) *GitWrap { 494 | return gw.Cmd("fetch", args...) 495 | } 496 | 497 | // Grep command for git 498 | func (gw *GitWrap) Grep(args ...string) *GitWrap { 499 | return gw.Cmd("grep", args...) 500 | } 501 | 502 | // Init command for git 503 | func (gw *GitWrap) Init(args ...string) *GitWrap { 504 | return gw.Cmd("init", args...) 505 | } 506 | 507 | // Log command for git 508 | func (gw *GitWrap) Log(args ...string) *GitWrap { 509 | return gw.Cmd("log", args...) 510 | } 511 | 512 | // Merge command for git 513 | func (gw *GitWrap) Merge(args ...string) *GitWrap { 514 | return gw.Cmd("merge", args...) 515 | } 516 | 517 | // Mv command for git 518 | func (gw *GitWrap) Mv(args ...string) *GitWrap { 519 | return gw.Cmd("mv", args...) 520 | } 521 | 522 | // Pull command for git 523 | func (gw *GitWrap) Pull(args ...string) *GitWrap { 524 | return gw.Cmd("pull", args...) 525 | } 526 | 527 | // Push command for git 528 | func (gw *GitWrap) Push(args ...string) *GitWrap { 529 | return gw.Cmd("push", args...) 530 | } 531 | 532 | // Rebase command for git 533 | func (gw *GitWrap) Rebase(args ...string) *GitWrap { 534 | return gw.Cmd("rebase", args...) 535 | } 536 | 537 | // Reflog command for git 538 | func (gw *GitWrap) Reflog(args ...string) *GitWrap { 539 | return gw.Cmd("reflog", args...) 540 | } 541 | 542 | // Remote command for git 543 | func (gw *GitWrap) Remote(args ...string) *GitWrap { 544 | return gw.Cmd("remote", args...) 545 | } 546 | 547 | // Reset command for git 548 | func (gw *GitWrap) Reset(args ...string) *GitWrap { 549 | return gw.Cmd("reset", args...) 550 | } 551 | 552 | // Restore command for git 553 | func (gw *GitWrap) Restore(args ...string) *GitWrap { 554 | return gw.Cmd("restore", args...) 555 | } 556 | 557 | // Revert command for git 558 | func (gw *GitWrap) Revert(args ...string) *GitWrap { 559 | return gw.Cmd("revert", args...) 560 | } 561 | 562 | // RevList command for git 563 | func (gw *GitWrap) RevList(args ...string) *GitWrap { 564 | return gw.Cmd("rev-list", args...) 565 | } 566 | 567 | // RevParse command for git 568 | // 569 | // rev-parse usage: 570 | // 571 | // git rev-parse --show-toplevel // get git workdir, repo dir. 572 | // git rev-parse -q --git-dir // get git data dir name. eg: .git 573 | func (gw *GitWrap) RevParse(args ...string) *GitWrap { 574 | return gw.Cmd("rev-parse", args...) 575 | } 576 | 577 | // Rm command for git 578 | func (gw *GitWrap) Rm(args ...string) *GitWrap { 579 | return gw.Cmd("rm", args...) 580 | } 581 | 582 | // ShortLog command for git 583 | func (gw *GitWrap) ShortLog(args ...string) *GitWrap { 584 | return gw.Cmd("shortlog", args...) 585 | } 586 | 587 | // Show command for git 588 | func (gw *GitWrap) Show(args ...string) *GitWrap { 589 | return gw.Cmd("show", args...) 590 | } 591 | 592 | // Stash command for git 593 | func (gw *GitWrap) Stash(args ...string) *GitWrap { 594 | return gw.Cmd("stash", args...) 595 | } 596 | 597 | // Status command for git 598 | func (gw *GitWrap) Status(args ...string) *GitWrap { 599 | return gw.Cmd("status", args...) 600 | } 601 | 602 | // Switch command for git 603 | func (gw *GitWrap) Switch(args ...string) *GitWrap { 604 | return gw.Cmd("switch", args...) 605 | } 606 | 607 | // Tag command for git 608 | func (gw *GitWrap) Tag(args ...string) *GitWrap { 609 | return gw.Cmd("tag", args...) 610 | } 611 | 612 | // Var command for git 613 | func (gw *GitWrap) Var(args ...string) *GitWrap { 614 | return gw.Cmd("var", args...) 615 | } 616 | 617 | // Worktree command for git 618 | func (gw *GitWrap) Worktree(args ...string) *GitWrap { 619 | return gw.Cmd("worktree", args...) 620 | } 621 | -------------------------------------------------------------------------------- /gmoji/emoji.go: -------------------------------------------------------------------------------- 1 | package gmoji 2 | 3 | import ( 4 | "embed" 5 | "encoding/json" 6 | "errors" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/gookit/goutil" 11 | "github.com/gookit/goutil/errorx" 12 | "github.com/gookit/goutil/strutil" 13 | ) 14 | 15 | //go:embed gitmojis.json gitmojis.zh-CN.json 16 | var emojiFs embed.FS 17 | 18 | var codeMatch = regexp.MustCompile(`(:\w+:)`) 19 | 20 | // Emoji struct 21 | type Emoji struct { 22 | Name string 23 | Code string 24 | 25 | Emoji string 26 | Entity string 27 | Semver string 28 | 29 | Description string 30 | } 31 | 32 | // ID name 33 | func (e *Emoji) ID() string { 34 | return strings.Trim(e.Code, ":") 35 | } 36 | 37 | // EmojiMap data. key is code name: Emoji.ID() 38 | type EmojiMap map[string]*Emoji 39 | 40 | // Get by code name 41 | func (em EmojiMap) Get(name string) *Emoji { 42 | return em[name] 43 | } 44 | 45 | // First emoji get 46 | func (em EmojiMap) First() *Emoji { 47 | for _, e := range em { 48 | return e 49 | } 50 | return nil 51 | } 52 | 53 | // Lookup by code name 54 | func (em EmojiMap) Lookup(name string) (*Emoji, bool) { 55 | e, ok := em[name] 56 | return e, ok 57 | } 58 | 59 | // CodeToEmoji convert 60 | func (em EmojiMap) CodeToEmoji(code string) string { 61 | return em.NameToEmoji(strings.Trim(code, ": ")) 62 | } 63 | 64 | // NameToEmoji convert 65 | func (em EmojiMap) NameToEmoji(name string) string { 66 | if e := em.Get(name); e != nil { 67 | return e.Emoji 68 | } 69 | return name 70 | } 71 | 72 | // RenderCodes to emojis 73 | func (em EmojiMap) RenderCodes(text string) string { 74 | // not contains emoji name. 75 | if strings.IndexByte(text, ':') == -1 { 76 | return text 77 | } 78 | 79 | return codeMatch.ReplaceAllStringFunc(text, func(code string) string { 80 | return em.CodeToEmoji(code) // + " " 81 | }) 82 | } 83 | 84 | // FindOne by keywords 85 | func (em EmojiMap) FindOne(keywords ...string) *Emoji { 86 | sub := em.Search(keywords, 1) 87 | if len(sub) > 0 { 88 | for _, e := range sub { 89 | return e 90 | } 91 | } 92 | return nil 93 | } 94 | 95 | // Search by keywords, will match name and description 96 | func (em EmojiMap) Search(keywords []string, limit int) EmojiMap { 97 | if limit <= 0 { 98 | limit = 10 99 | } 100 | 101 | sub := make(EmojiMap, limit) 102 | for name, emoji := range em { 103 | var matched = true 104 | for _, pattern := range keywords { 105 | if strutil.QuickMatch(pattern, name) { 106 | continue 107 | } 108 | if strutil.QuickMatch(pattern, emoji.Description) { 109 | continue 110 | } 111 | 112 | matched = false 113 | break 114 | } 115 | 116 | if matched { 117 | sub[name] = emoji 118 | if len(sub) >= limit { 119 | break 120 | } 121 | } 122 | } 123 | 124 | return sub 125 | } 126 | 127 | // Len of map 128 | func (em EmojiMap) Len() int { 129 | return len(em) 130 | } 131 | 132 | // String format 133 | func (em EmojiMap) String() string { 134 | var sb strutil.Builder 135 | sb.Grow(len(em) * 16) 136 | 137 | for _, emoji := range em { 138 | sb.Writef("%28s %s %s\n", emoji.Code, emoji.Emoji, emoji.Description) 139 | } 140 | return sb.String() 141 | } 142 | 143 | // languages 144 | const ( 145 | LangEN = "en" 146 | LangZH = "zh-CN" 147 | ) 148 | 149 | // key is language 150 | var cache = make(map[string]EmojiMap, 2) 151 | 152 | func tryLoad(lang string) (err error) { 153 | if _, ok := cache[lang]; ok { 154 | return nil 155 | } 156 | 157 | var bs []byte 158 | if lang == LangEN { 159 | bs, err = emojiFs.ReadFile("gitmojis.json") 160 | } else if lang == LangZH { 161 | bs, err = emojiFs.ReadFile("gitmojis." + lang + ".json") 162 | } else { 163 | err = errors.New("git-emoji: unsupported lang " + lang) 164 | } 165 | 166 | if err == nil { 167 | ls := make([]*Emoji, 64) 168 | err = json.Unmarshal(bs, &ls) 169 | if err == nil { 170 | em := make(EmojiMap) 171 | for _, e := range ls { 172 | em[e.ID()] = e 173 | } 174 | cache[lang] = em 175 | } 176 | } 177 | return 178 | } 179 | 180 | // Emojis for given language 181 | func Emojis(lang string) (EmojiMap, error) { 182 | if lang == "" { 183 | lang = LangEN 184 | } 185 | 186 | err := tryLoad(lang) 187 | if err != nil { 188 | return nil, err 189 | } 190 | 191 | em, ok := cache[lang] 192 | if !ok { 193 | err = errorx.Rawf("emoji map data not found for lang %q", lang) 194 | } 195 | return em, err 196 | } 197 | 198 | // MustEmojis load and get 199 | func MustEmojis(lang string) EmojiMap { 200 | return goutil.Must(Emojis(lang)) 201 | } 202 | -------------------------------------------------------------------------------- /gmoji/emoji_test.go: -------------------------------------------------------------------------------- 1 | package gmoji_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gookit/gitw/gmoji" 7 | "github.com/gookit/goutil/testutil/assert" 8 | ) 9 | 10 | func TestEmojiMap_String(t *testing.T) { 11 | em, err := gmoji.Emojis("") 12 | assert.NoErr(t, err) 13 | assert.NotEmpty(t, em) 14 | 15 | // fmt.Println(em.String()) 16 | 17 | em, err = gmoji.Emojis(gmoji.LangZH) 18 | assert.NoErr(t, err) 19 | assert.NotEmpty(t, em) 20 | // fmt.Println(em.String()) 21 | } 22 | -------------------------------------------------------------------------------- /gmoji/gitmojis.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "emoji": "🎨", 4 | "entity": "🎨", 5 | "code": ":art:", 6 | "description": "Improve structure / format of the code.", 7 | "name": "art", 8 | "semver": null 9 | }, 10 | { 11 | "emoji": "⚡️", 12 | "entity": "⚡", 13 | "code": ":zap:", 14 | "description": "Improve performance.", 15 | "name": "zap", 16 | "semver": "patch" 17 | }, 18 | { 19 | "emoji": "🔥", 20 | "entity": "🔥", 21 | "code": ":fire:", 22 | "description": "Remove code or files.", 23 | "name": "fire", 24 | "semver": null 25 | }, 26 | { 27 | "emoji": "🐛", 28 | "entity": "🐛", 29 | "code": ":bug:", 30 | "description": "Fix a bug.", 31 | "name": "bug", 32 | "semver": "patch" 33 | }, 34 | { 35 | "emoji": "🚑️", 36 | "entity": "🚑", 37 | "code": ":ambulance:", 38 | "description": "Critical hotfix.", 39 | "name": "ambulance", 40 | "semver": "patch" 41 | }, 42 | { 43 | "emoji": "✨", 44 | "entity": "✨", 45 | "code": ":sparkles:", 46 | "description": "Introduce new features.", 47 | "name": "sparkles", 48 | "semver": "minor" 49 | }, 50 | { 51 | "emoji": "📝", 52 | "entity": "📝", 53 | "code": ":memo:", 54 | "description": "Add or update documentation.", 55 | "name": "memo", 56 | "semver": null 57 | }, 58 | { 59 | "emoji": "🚀", 60 | "entity": "🚀", 61 | "code": ":rocket:", 62 | "description": "Deploy stuff.", 63 | "name": "rocket", 64 | "semver": null 65 | }, 66 | { 67 | "emoji": "💄", 68 | "entity": "&#ff99cc;", 69 | "code": ":lipstick:", 70 | "description": "Add or update the UI and style files.", 71 | "name": "lipstick", 72 | "semver": "patch" 73 | }, 74 | { 75 | "emoji": "🎉", 76 | "entity": "🎉", 77 | "code": ":tada:", 78 | "description": "Begin a project.", 79 | "name": "tada", 80 | "semver": null 81 | }, 82 | { 83 | "emoji": "✅", 84 | "entity": "✅", 85 | "code": ":white_check_mark:", 86 | "description": "Add, update, or pass tests.", 87 | "name": "white-check-mark", 88 | "semver": null 89 | }, 90 | { 91 | "emoji": "🔒️", 92 | "entity": "🔒", 93 | "code": ":lock:", 94 | "description": "Fix security issues.", 95 | "name": "lock", 96 | "semver": "patch" 97 | }, 98 | { 99 | "emoji": "🔐", 100 | "entity": "🔐", 101 | "code": ":closed_lock_with_key:", 102 | "description": "Add or update secrets.", 103 | "name": "closed-lock-with-key", 104 | "semver": null 105 | }, 106 | { 107 | "emoji": "🔖", 108 | "entity": "🔖", 109 | "code": ":bookmark:", 110 | "description": "Release / Version tags.", 111 | "name": "bookmark", 112 | "semver": null 113 | }, 114 | { 115 | "emoji": "🚨", 116 | "entity": "🚨", 117 | "code": ":rotating_light:", 118 | "description": "Fix compiler / linter warnings.", 119 | "name": "rotating-light", 120 | "semver": null 121 | }, 122 | { 123 | "emoji": "🚧", 124 | "entity": "🚧", 125 | "code": ":construction:", 126 | "description": "Work in progress.", 127 | "name": "construction", 128 | "semver": null 129 | }, 130 | { 131 | "emoji": "💚", 132 | "entity": "💚", 133 | "code": ":green_heart:", 134 | "description": "Fix CI Build.", 135 | "name": "green-heart", 136 | "semver": null 137 | }, 138 | { 139 | "emoji": "⬇️", 140 | "entity": "⬇️", 141 | "code": ":arrow_down:", 142 | "description": "Downgrade dependencies.", 143 | "name": "arrow-down", 144 | "semver": "patch" 145 | }, 146 | { 147 | "emoji": "⬆️", 148 | "entity": "⬆️", 149 | "code": ":arrow_up:", 150 | "description": "Upgrade dependencies.", 151 | "name": "arrow-up", 152 | "semver": "patch" 153 | }, 154 | { 155 | "emoji": "📌", 156 | "entity": "📌", 157 | "code": ":pushpin:", 158 | "description": "Pin dependencies to specific versions.", 159 | "name": "pushpin", 160 | "semver": "patch" 161 | }, 162 | { 163 | "emoji": "👷", 164 | "entity": "👷", 165 | "code": ":construction_worker:", 166 | "description": "Add or update CI build system.", 167 | "name": "construction-worker", 168 | "semver": null 169 | }, 170 | { 171 | "emoji": "📈", 172 | "entity": "📈", 173 | "code": ":chart_with_upwards_trend:", 174 | "description": "Add or update analytics or track code.", 175 | "name": "chart-with-upwards-trend", 176 | "semver": "patch" 177 | }, 178 | { 179 | "emoji": "♻️", 180 | "entity": "♻", 181 | "code": ":recycle:", 182 | "description": "Refactor code.", 183 | "name": "recycle", 184 | "semver": null 185 | }, 186 | { 187 | "emoji": "➕", 188 | "entity": "➕", 189 | "code": ":heavy_plus_sign:", 190 | "description": "Add a dependency.", 191 | "name": "heavy-plus-sign", 192 | "semver": "patch" 193 | }, 194 | { 195 | "emoji": "➖", 196 | "entity": "➖", 197 | "code": ":heavy_minus_sign:", 198 | "description": "Remove a dependency.", 199 | "name": "heavy-minus-sign", 200 | "semver": "patch" 201 | }, 202 | { 203 | "emoji": "🔧", 204 | "entity": "🔧", 205 | "code": ":wrench:", 206 | "description": "Add or update configuration files.", 207 | "name": "wrench", 208 | "semver": "patch" 209 | }, 210 | { 211 | "emoji": "🔨", 212 | "entity": "🔨", 213 | "code": ":hammer:", 214 | "description": "Add or update development scripts.", 215 | "name": "hammer", 216 | "semver": null 217 | }, 218 | { 219 | "emoji": "🌐", 220 | "entity": "🌐", 221 | "code": ":globe_with_meridians:", 222 | "description": "Internationalization and localization.", 223 | "name": "globe-with-meridians", 224 | "semver": "patch" 225 | }, 226 | { 227 | "emoji": "✏️", 228 | "entity": "", 229 | "code": ":pencil2:", 230 | "description": "Fix typos.", 231 | "name": "pencil2", 232 | "semver": "patch" 233 | }, 234 | { 235 | "emoji": "💩", 236 | "entity": "", 237 | "code": ":poop:", 238 | "description": "Write bad code that needs to be improved.", 239 | "name": "poop", 240 | "semver": null 241 | }, 242 | { 243 | "emoji": "⏪️", 244 | "entity": "⏪", 245 | "code": ":rewind:", 246 | "description": "Revert changes.", 247 | "name": "rewind", 248 | "semver": "patch" 249 | }, 250 | { 251 | "emoji": "🔀", 252 | "entity": "🔀", 253 | "code": ":twisted_rightwards_arrows:", 254 | "description": "Merge branches.", 255 | "name": "twisted-rightwards-arrows", 256 | "semver": null 257 | }, 258 | { 259 | "emoji": "📦️", 260 | "entity": "F4E6;", 261 | "code": ":package:", 262 | "description": "Add or update compiled files or packages.", 263 | "name": "package", 264 | "semver": "patch" 265 | }, 266 | { 267 | "emoji": "👽️", 268 | "entity": "F47D;", 269 | "code": ":alien:", 270 | "description": "Update code due to external API changes.", 271 | "name": "alien", 272 | "semver": "patch" 273 | }, 274 | { 275 | "emoji": "🚚", 276 | "entity": "F69A;", 277 | "code": ":truck:", 278 | "description": "Move or rename resources (e.g.: files, paths, routes).", 279 | "name": "truck", 280 | "semver": null 281 | }, 282 | { 283 | "emoji": "📄", 284 | "entity": "F4C4;", 285 | "code": ":page_facing_up:", 286 | "description": "Add or update license.", 287 | "name": "page-facing-up", 288 | "semver": null 289 | }, 290 | { 291 | "emoji": "💥", 292 | "entity": "💥", 293 | "code": ":boom:", 294 | "description": "Introduce breaking changes.", 295 | "name": "boom", 296 | "semver": "major" 297 | }, 298 | { 299 | "emoji": "🍱", 300 | "entity": "F371", 301 | "code": ":bento:", 302 | "description": "Add or update assets.", 303 | "name": "bento", 304 | "semver": "patch" 305 | }, 306 | { 307 | "emoji": "♿️", 308 | "entity": "♿", 309 | "code": ":wheelchair:", 310 | "description": "Improve accessibility.", 311 | "name": "wheelchair", 312 | "semver": "patch" 313 | }, 314 | { 315 | "emoji": "💡", 316 | "entity": "💡", 317 | "code": ":bulb:", 318 | "description": "Add or update comments in source code.", 319 | "name": "bulb", 320 | "semver": null 321 | }, 322 | { 323 | "emoji": "🍻", 324 | "entity": "🍻", 325 | "code": ":beers:", 326 | "description": "Write code drunkenly.", 327 | "name": "beers", 328 | "semver": null 329 | }, 330 | { 331 | "emoji": "💬", 332 | "entity": "💬", 333 | "code": ":speech_balloon:", 334 | "description": "Add or update text and literals.", 335 | "name": "speech-balloon", 336 | "semver": "patch" 337 | }, 338 | { 339 | "emoji": "🗃️", 340 | "entity": "🗃", 341 | "code": ":card_file_box:", 342 | "description": "Perform database related changes.", 343 | "name": "card-file-box", 344 | "semver": "patch" 345 | }, 346 | { 347 | "emoji": "🔊", 348 | "entity": "🔊", 349 | "code": ":loud_sound:", 350 | "description": "Add or update logs.", 351 | "name": "loud-sound", 352 | "semver": null 353 | }, 354 | { 355 | "emoji": "🔇", 356 | "entity": "🔇", 357 | "code": ":mute:", 358 | "description": "Remove logs.", 359 | "name": "mute", 360 | "semver": null 361 | }, 362 | { 363 | "emoji": "👥", 364 | "entity": "👥", 365 | "code": ":busts_in_silhouette:", 366 | "description": "Add or update contributor(s).", 367 | "name": "busts-in-silhouette", 368 | "semver": null 369 | }, 370 | { 371 | "emoji": "🚸", 372 | "entity": "🚸", 373 | "code": ":children_crossing:", 374 | "description": "Improve user experience / usability.", 375 | "name": "children-crossing", 376 | "semver": "patch" 377 | }, 378 | { 379 | "emoji": "🏗️", 380 | "entity": "f3d7;", 381 | "code": ":building_construction:", 382 | "description": "Make architectural changes.", 383 | "name": "building-construction", 384 | "semver": null 385 | }, 386 | { 387 | "emoji": "📱", 388 | "entity": "📱", 389 | "code": ":iphone:", 390 | "description": "Work on responsive design.", 391 | "name": "iphone", 392 | "semver": "patch" 393 | }, 394 | { 395 | "emoji": "🤡", 396 | "entity": "🤡", 397 | "code": ":clown_face:", 398 | "description": "Mock things.", 399 | "name": "clown-face", 400 | "semver": null 401 | }, 402 | { 403 | "emoji": "🥚", 404 | "entity": "🥚", 405 | "code": ":egg:", 406 | "description": "Add or update an easter egg.", 407 | "name": "egg", 408 | "semver": "patch" 409 | }, 410 | { 411 | "emoji": "🙈", 412 | "entity": "bdfe7;", 413 | "code": ":see_no_evil:", 414 | "description": "Add or update a .gitignore file.", 415 | "name": "see-no-evil", 416 | "semver": null 417 | }, 418 | { 419 | "emoji": "📸", 420 | "entity": "📸", 421 | "code": ":camera_flash:", 422 | "description": "Add or update snapshots.", 423 | "name": "camera-flash", 424 | "semver": null 425 | }, 426 | { 427 | "emoji": "⚗️", 428 | "entity": "📸", 429 | "code": ":alembic:", 430 | "description": "Perform experiments.", 431 | "name": "alembic", 432 | "semver": "patch" 433 | }, 434 | { 435 | "emoji": "🔍️", 436 | "entity": "🔍", 437 | "code": ":mag:", 438 | "description": "Improve SEO.", 439 | "name": "mag", 440 | "semver": "patch" 441 | }, 442 | { 443 | "emoji": "🏷️", 444 | "entity": "🏷", 445 | "code": ":label:", 446 | "description": "Add or update types.", 447 | "name": "label", 448 | "semver": "patch" 449 | }, 450 | { 451 | "emoji": "🌱", 452 | "entity": "🌱", 453 | "code": ":seedling:", 454 | "description": "Add or update seed files.", 455 | "name": "seedling", 456 | "semver": null 457 | }, 458 | { 459 | "emoji": "🚩", 460 | "entity": "🚩", 461 | "code": ":triangular_flag_on_post:", 462 | "description": "Add, update, or remove feature flags.", 463 | "name": "triangular-flag-on-post", 464 | "semver": "patch" 465 | }, 466 | { 467 | "emoji": "🥅", 468 | "entity": "🥅", 469 | "code": ":goal_net:", 470 | "description": "Catch errors.", 471 | "name": "goal-net", 472 | "semver": "patch" 473 | }, 474 | { 475 | "emoji": "💫", 476 | "entity": "💫", 477 | "code": ":dizzy:", 478 | "description": "Add or update animations and transitions.", 479 | "name": "animation", 480 | "semver": "patch" 481 | }, 482 | { 483 | "emoji": "🗑️", 484 | "entity": "🗑", 485 | "code": ":wastebasket:", 486 | "description": "Deprecate code that needs to be cleaned up.", 487 | "name": "wastebasket", 488 | "semver": "patch" 489 | }, 490 | { 491 | "emoji": "🛂", 492 | "entity": "🛂", 493 | "code": ":passport_control:", 494 | "description": "Work on code related to authorization, roles and permissions.", 495 | "name": "passport-control", 496 | "semver": "patch" 497 | }, 498 | { 499 | "emoji": "🩹", 500 | "entity": "🩹", 501 | "code": ":adhesive_bandage:", 502 | "description": "Simple fix for a non-critical issue.", 503 | "name": "adhesive-bandage", 504 | "semver": "patch" 505 | }, 506 | { 507 | "emoji": "🧐", 508 | "entity": "🧐", 509 | "code": ":monocle_face:", 510 | "description": "Data exploration/inspection.", 511 | "name": "monocle-face", 512 | "semver": null 513 | }, 514 | { 515 | "emoji": "⚰️", 516 | "entity": "⚰", 517 | "code": ":coffin:", 518 | "description": "Remove dead code.", 519 | "name": "coffin", 520 | "semver": null 521 | }, 522 | { 523 | "emoji": "🧪", 524 | "entity": "🧪", 525 | "code": ":test_tube:", 526 | "description": "Add a failing test.", 527 | "name": "test-tube", 528 | "semver": null 529 | }, 530 | { 531 | "emoji": "👔", 532 | "entity": "👔", 533 | "code": ":necktie:", 534 | "description": "Add or update business logic.", 535 | "name": "necktie", 536 | "semver": "patch" 537 | }, 538 | { 539 | "emoji": "🩺", 540 | "entity": "🩺", 541 | "code": ":stethoscope:", 542 | "description": "Add or update healthcheck.", 543 | "name": "stethoscope", 544 | "semver": null 545 | }, 546 | { 547 | "emoji": "🧱", 548 | "entity": "🧱", 549 | "code": ":bricks:", 550 | "description": "Infrastructure related changes.", 551 | "name": "bricks", 552 | "semver": null 553 | }, 554 | { 555 | "emoji": "🧑‍💻", 556 | "entity": "🧑‍💻", 557 | "code": ":technologist:", 558 | "description": "Improve developer experience.", 559 | "name": "technologist", 560 | "semver": null 561 | }, 562 | { 563 | "emoji": "💸", 564 | "entity": "💸", 565 | "code": ":money_with_wings:", 566 | "description": "Add sponsorships or money related infrastructure.", 567 | "name": "money-with-wings", 568 | "semver": null 569 | }, 570 | { 571 | "emoji": "🧵", 572 | "entity": "🧵", 573 | "code": ":thread:", 574 | "description": "Add or update code related to multithreading or concurrency.", 575 | "name": "thread", 576 | "semver": null 577 | }, 578 | { 579 | "emoji": "🦺", 580 | "entity": "🦺", 581 | "code": ":safety_vest:", 582 | "description": "Add or update code related to validation.", 583 | "name": "safety-vest", 584 | "semver": null 585 | } 586 | ] 587 | 588 | -------------------------------------------------------------------------------- /gmoji/gitmojis.zh-CN.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "emoji": "🎨", 4 | "name": "调色板", 5 | "code": ":art:", 6 | "description": "改进代码结构/代码格式" 7 | }, 8 | { 9 | "emoji": "🔥", 10 | "name": "火焰", 11 | "code": ":fire:", 12 | "description": "移除代码或文件" 13 | }, 14 | { 15 | "emoji": "🐛", 16 | "name": "bug", 17 | "code": ":bug:", 18 | "description": "修复 bug" 19 | }, 20 | { 21 | "emoji": "🚑", 22 | "name": "急救车", 23 | "code": ":ambulance:", 24 | "description": "重要补丁" 25 | }, 26 | { 27 | "emoji": "✨", 28 | "name": "火花", 29 | "code": ":sparkles:", 30 | "description": "引入新功能" 31 | }, 32 | { 33 | "emoji": "📝", 34 | "name": "备忘录", 35 | "code": ":memo:", 36 | "description": "撰写文档" 37 | }, 38 | { 39 | "emoji": "🚀", 40 | "name": "火箭", 41 | "code": ":rocket:", 42 | "description": "部署功能" 43 | }, 44 | { 45 | "emoji": "💄", 46 | "name": "口红", 47 | "code": ":lipstick:", 48 | "description": "更新 UI 和样式文件" 49 | }, 50 | { 51 | "emoji": "🎉", 52 | "name": "庆祝", 53 | "code": ":tada:", 54 | "description": "初次提交" 55 | }, 56 | { 57 | "emoji": "✅", 58 | "name": "白色复选框", 59 | "code": ":white_check_mark:", 60 | "description": "增加测试" 61 | }, 62 | { 63 | "emoji": "🔒", 64 | "name": "锁", 65 | "code": ":lock:", 66 | "description": "修复安全问题" 67 | }, 68 | { 69 | "emoji": "🍎", 70 | "name": "苹果", 71 | "code": ":apple:", 72 | "description": "修复 macOS 下的内容" 73 | }, 74 | { 75 | "emoji": "🐧", 76 | "name": "企鹅", 77 | "code": ":penguin:", 78 | "description": "修复 Linux 下的内容" 79 | }, 80 | { 81 | "emoji": "🏁", 82 | "name": "旗帜", 83 | "code": ":checked_flag:", 84 | "description": "修复 Windows 下的内容" 85 | }, 86 | { 87 | "emoji": "🤖", 88 | "name": "Android机器人", 89 | "code": ":robot:", 90 | "description": "修复Android上的某些内容。" 91 | }, 92 | { 93 | "emoji": "🍏", 94 | "name": "绿苹果", 95 | "code": ":green_apple:", 96 | "description": "解决iOS上的某些问题。" 97 | }, 98 | { 99 | "emoji": "🔖", 100 | "name": "书签", 101 | "code": ":bookmark:", 102 | "description": "发行/版本标签" 103 | }, 104 | { 105 | "emoji": "🚨", 106 | "name": "警车灯", 107 | "code": ":rotating_light:", 108 | "description": "移除 linter 警告" 109 | }, 110 | { 111 | "emoji": "🚧", 112 | "name": "施工", 113 | "code": ":construction:", 114 | "description": "工作进行中" 115 | }, 116 | { 117 | "emoji": "💚", 118 | "name": "绿心", 119 | "code": ":green_heart:", 120 | "description": "修复 CI 构建问题" 121 | }, 122 | { 123 | "emoji": "⬇️", 124 | "name": "下降箭头", 125 | "code": ":arrow_down:", 126 | "description": "降级依赖" 127 | }, 128 | { 129 | "emoji": "⬆️", 130 | "name": "上升箭头", 131 | "code": ":arrow_up:", 132 | "description": "升级依赖" 133 | }, 134 | { 135 | "emoji": "📌", 136 | "name": "图钉", 137 | "code": ":pushpin:", 138 | "description": "将依赖关系固定到特定版本。" 139 | }, 140 | { 141 | "emoji": "👷", 142 | "name": "工人", 143 | "code": ":construction_worker:", 144 | "description": "添加 CI 构建系统" 145 | }, 146 | { 147 | "emoji": "📈", 148 | "name": "上升趋势图", 149 | "code": ":chart_with_upwards_trend:", 150 | "description": "添加分析或跟踪代码" 151 | }, 152 | { 153 | "emoji": "♻️", 154 | "name": "循环箭头", 155 | "code": ":recycle:", 156 | "description": "重构代码。" 157 | }, 158 | { 159 | "emoji": "🔨", 160 | "name": "锤子", 161 | "code": ":hammer:", 162 | "description": "重大重构" 163 | }, 164 | { 165 | "emoji": "➖", 166 | "name": "减号", 167 | "code": ":heavy_minus_sign:", 168 | "description": "减少一个依赖" 169 | }, 170 | { 171 | "emoji": "🐳", 172 | "name": "鲸鱼", 173 | "code": ":whale:", 174 | "description": "Docker 相关工作" 175 | }, 176 | { 177 | "emoji": "➕", 178 | "name": "加号", 179 | "code": ":heavy_plus_sign:", 180 | "description": "增加一个依赖" 181 | }, 182 | { 183 | "emoji": "🔧", 184 | "name": "扳手", 185 | "code": ":wrench:", 186 | "description": "修改配置文件" 187 | }, 188 | { 189 | "emoji": "🌐", 190 | "name": "地球", 191 | "code": ":globe_with_meridians:", 192 | "description": "国际化与本地化" 193 | }, 194 | { 195 | "emoji": "✏️", 196 | "name": "铅笔", 197 | "code": ":pencil2:", 198 | "description": "修复 typo" 199 | }, 200 | { 201 | "emoji": "💩", 202 | "name": "瞪眼", 203 | "code": ":hankey:", 204 | "description": "编写需要改进的错误代码。" 205 | }, 206 | { 207 | "emoji": "⏪", 208 | "name": "双左箭头", 209 | "code": ":rewind:", 210 | "description": "恢复更改。" 211 | }, 212 | { 213 | "emoji": "🔀", 214 | "name": "双合并箭头", 215 | "code": ":twisted_rightwards_arrows:", 216 | "description": "合并分支。" 217 | }, 218 | { 219 | "emoji": "📦", 220 | "name": "箱子", 221 | "code": ":package:", 222 | "description": "更新编译的文件或包。" 223 | }, 224 | { 225 | "emoji": "👽", 226 | "name": "面具", 227 | "code": ":alien:", 228 | "description": "由于外部API更改而更新代码。" 229 | }, 230 | { 231 | "emoji": "🚚", 232 | "name": "面包车", 233 | "code": ":truck:", 234 | "description": "移动或重命名文件。" 235 | }, 236 | { 237 | "emoji": "📄", 238 | "name": "文档", 239 | "code": ":page_facing_up:", 240 | "description": "添加或更新许可证。" 241 | }, 242 | { 243 | "emoji": "💥", 244 | "name": "爆炸", 245 | "code": ":boom:", 246 | "description": "介绍突破性变化。" 247 | }, 248 | { 249 | "emoji": "🍱", 250 | "name": "装满餐盘", 251 | "code": ":bento:", 252 | "description": "添加或更新资产。" 253 | }, 254 | { 255 | "emoji": "👌", 256 | "name": "OK手势", 257 | "code": ":ok_hand:", 258 | "description": "由于代码审查更改而更新代码。" 259 | }, 260 | { 261 | "emoji": "♿", 262 | "name": "坐姿", 263 | "code": ":wheelchair:", 264 | "description": "提高可访问性。" 265 | }, 266 | { 267 | "emoji": "💡", 268 | "name": "灯泡", 269 | "code": ":bulb:", 270 | "description": "记录源代码。" 271 | }, 272 | { 273 | "emoji": "🍻", 274 | "name": "干杯", 275 | "code": ":beers:", 276 | "description": "醉生梦死的写代码。" 277 | }, 278 | { 279 | "emoji": "💬", 280 | "name": "提示栏", 281 | "code": ":speech_balloon:", 282 | "description": "更新文字和文字。" 283 | }, 284 | { 285 | "emoji": "🗃️", 286 | "name": "卡片盒子", 287 | "code": ":card_file_box:", 288 | "description": "执行与数据库相关的更改。" 289 | }, 290 | { 291 | "emoji": "🔊", 292 | "name": "有声喇叭", 293 | "code": ":loud_sound:", 294 | "description": "添加日志。" 295 | }, 296 | { 297 | "emoji": "🔇", 298 | "name": "静音喇叭", 299 | "code": ":mute:", 300 | "description": "删除日志。" 301 | }, 302 | { 303 | "emoji": "👥", 304 | "name": "两个人头", 305 | "code": ":busts_in_silhouette:", 306 | "description": "添加贡献者。" 307 | }, 308 | { 309 | "emoji": "🚸", 310 | "name": "小盆友", 311 | "code": ":children_crossing:", 312 | "description": "改善用户体验/可用性。" 313 | }, 314 | { 315 | "emoji": "🏗️", 316 | "name": "吊车", 317 | "code": ":building_construction:", 318 | "description": "进行架构更改。" 319 | }, 320 | { 321 | "emoji": "📱", 322 | "name": "手机", 323 | "code": ":iphone:", 324 | "description": "致力于响应式设计。" 325 | }, 326 | { 327 | "emoji": "🤡", 328 | "name": "小丑", 329 | "code": ":clown_face:", 330 | "description": "嘲笑事物。" 331 | }, 332 | { 333 | "emoji": "🥚", 334 | "name": "彩蛋", 335 | "code": ":egg:", 336 | "description": "添加一个复活节彩蛋。" 337 | }, 338 | { 339 | "emoji": "🙈", 340 | "name": "蒙眼猴子", 341 | "code": ":see_no_evil:", 342 | "description": "添加或更新.gitignore文件。" 343 | }, 344 | { 345 | "emoji": "📸", 346 | "name": "照相机", 347 | "code": ":camera_flash:", 348 | "description": "添加或更新快照。" 349 | }, 350 | { 351 | "emoji": "👔", 352 | "entity": "👔", 353 | "code": ":necktie:", 354 | "description": "添加或更新业务逻辑。", 355 | "name": "领带", 356 | "semver": "patch" 357 | } 358 | ] -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gookit/gitw 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/gookit/color v1.5.4 7 | github.com/gookit/goutil v0.6.16 8 | github.com/gookit/slog v0.5.6 9 | ) 10 | 11 | require ( 12 | github.com/gookit/gsr v0.1.0 // indirect 13 | github.com/valyala/bytebufferpool v1.0.0 // indirect 14 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 15 | golang.org/x/sync v0.7.0 // indirect 16 | golang.org/x/sys v0.22.0 // indirect 17 | golang.org/x/term v0.22.0 // indirect 18 | golang.org/x/text v0.16.0 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= 3 | github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= 4 | github.com/gookit/goutil v0.6.16 h1:9fRMCF4X9abdRD5+2HhBS/GwafjBlTUBjRtA5dgkvuw= 5 | github.com/gookit/goutil v0.6.16/go.mod h1:op2q8AoPDFSiY2+qkHxcBWQMYxOLQ1GbLXqe7vrwscI= 6 | github.com/gookit/gsr v0.1.0 h1:0gadWaYGU4phMs0bma38t+Do5OZowRMEVlHv31p0Zig= 7 | github.com/gookit/gsr v0.1.0/go.mod h1:7wv4Y4WCnil8+DlDYHBjidzrEzfHhXEoFjEA0pPPWpI= 8 | github.com/gookit/slog v0.5.6 h1:fmh+7bfOK8CjidMCwE+M3S8G766oHJpT/1qdmXGALCI= 9 | github.com/gookit/slog v0.5.6/go.mod h1:RfIwzoaQ8wZbKdcqG7+3EzbkMqcp2TUn3mcaSZAw2EQ= 10 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 11 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 12 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 13 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 14 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 15 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 16 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 17 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 18 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 19 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 20 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 21 | golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= 22 | golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= 23 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 24 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 25 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 26 | -------------------------------------------------------------------------------- /info.go: -------------------------------------------------------------------------------- 1 | package gitw 2 | 3 | // some consts for remote info 4 | const ( 5 | ProtoSSH = "ssh" 6 | ProtoHTTP = "http" 7 | 8 | SchemeGIT = "git" 9 | SchemeHTTP = "http" 10 | SchemeHTTPS = "https" 11 | ) 12 | 13 | // RepoInfo struct 14 | type RepoInfo struct { 15 | Name string 16 | Path string 17 | Dir string 18 | URL string 19 | // LastHash last commit hash value 20 | LastHash string 21 | Branch string 22 | Version string 23 | // Upstream remote name 24 | Upstream string 25 | // Remotes name and url mapping. 26 | Remotes map[string]string 27 | } 28 | -------------------------------------------------------------------------------- /info_branch.go: -------------------------------------------------------------------------------- 1 | package gitw 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gookit/gitw/brinfo" 7 | "github.com/gookit/goutil/basefn" 8 | "github.com/gookit/goutil/strutil" 9 | ) 10 | 11 | // RemotePfxOnBranch prefix keywords 12 | const RemotePfxOnBranch = "remotes/" 13 | 14 | // BranchInfo for a git branch 15 | type BranchInfo struct { 16 | // Current active branch 17 | Current bool 18 | // Name The full branch name. eg: fea_xx, remotes/origin/fea_xx 19 | Name string 20 | // Short only branch name. local branch is equals Name. eg: fea_xx 21 | Short string 22 | // Hash commit hash ID. 23 | Hash string 24 | // HashMsg commit hash message. 25 | HashMsg string 26 | // Alias name 27 | Alias string 28 | // Remote name. local branch is empty. eg: origin 29 | Remote string 30 | } 31 | 32 | // NewBranchInfo from branch line text 33 | func NewBranchInfo(line string) (*BranchInfo, error) { 34 | return ParseBranchLine(line, isVerboseBranchLine(line)) 35 | } 36 | 37 | // IsValid branch check 38 | func (b *BranchInfo) IsValid() bool { 39 | return b.Name != "" 40 | } 41 | 42 | // IsRemoted branch check 43 | func (b *BranchInfo) IsRemoted() bool { 44 | return strings.HasPrefix(b.Name, RemotePfxOnBranch) 45 | } 46 | 47 | // SetName for branch and parse 48 | func (b *BranchInfo) SetName(name string) { 49 | b.Name = name 50 | b.ParseName() 51 | } 52 | 53 | // ParseName for get remote and short name. 54 | func (b *BranchInfo) ParseName() *BranchInfo { 55 | b.Short = b.Name 56 | 57 | if b.IsRemoted() { 58 | // remove prefix "remotes" 59 | b.Remote, b.Short = strutil.QuietCut(b.Name[8:], "/") 60 | } 61 | return b 62 | } 63 | 64 | // branch types 65 | const ( 66 | BranchLocal = "local" 67 | BranchRemote = "remote" 68 | ) 69 | 70 | // BranchInfos for a git repo 71 | type BranchInfos struct { 72 | parsed bool 73 | // last parse err 74 | err error 75 | // raw branch lines by git branch 76 | brLines []string 77 | 78 | current *BranchInfo 79 | // local branch list 80 | locales []*BranchInfo 81 | // all remote branch list 82 | remotes []*BranchInfo 83 | } 84 | 85 | // EmptyBranchInfos instance 86 | func EmptyBranchInfos() *BranchInfos { 87 | return &BranchInfos{ 88 | // locales: make(map[string]*BranchInfo), 89 | // remotes: make(map[string]*BranchInfo), 90 | } 91 | } 92 | 93 | // NewBranchInfos create 94 | func NewBranchInfos(gitOut string) *BranchInfos { 95 | return &BranchInfos{ 96 | brLines: strings.Split(strings.TrimSpace(gitOut), "\n"), 97 | // locales: make(map[string]*BranchInfo), 98 | // remotes: make(map[string]*BranchInfo), 99 | } 100 | } 101 | 102 | // Parse given branch lines 103 | func (bs *BranchInfos) Parse() *BranchInfos { 104 | if len(bs.brLines) == 0 { 105 | return bs 106 | } 107 | 108 | if bs.parsed { 109 | return bs 110 | } 111 | 112 | bs.parsed = true 113 | verbose := isVerboseBranchLine(bs.brLines[0]) 114 | 115 | for _, line := range bs.brLines { 116 | if len(line) == 0 { 117 | continue 118 | } 119 | 120 | // parse line 121 | info, err := ParseBranchLine(line, verbose) 122 | if err != nil { 123 | bs.err = err 124 | continue 125 | } 126 | 127 | // collect 128 | if info.IsRemoted() { 129 | bs.remotes = append(bs.remotes, info) 130 | } else { 131 | bs.locales = append(bs.locales, info) 132 | if info.Current { 133 | bs.current = info 134 | } 135 | } 136 | } 137 | 138 | return bs 139 | } 140 | 141 | // HasLocal branch check 142 | func (bs *BranchInfos) HasLocal(branch string) bool { 143 | return bs.GetByName(branch) != nil 144 | } 145 | 146 | // HasRemote branch check 147 | func (bs *BranchInfos) HasRemote(branch, remote string) bool { 148 | return bs.GetByName(branch, remote) != nil 149 | } 150 | 151 | // IsExists branch check 152 | func (bs *BranchInfos) IsExists(branch string, remote ...string) bool { 153 | return bs.GetByName(branch, remote...) != nil 154 | } 155 | 156 | // GetByName find branch by name 157 | func (bs *BranchInfos) GetByName(branch string, remote ...string) *BranchInfo { 158 | if len(remote) > 0 && remote[0] != "" { 159 | for _, info := range bs.remotes { 160 | if info.Remote == remote[0] && branch == info.Short { 161 | return info 162 | } 163 | } 164 | return nil 165 | } 166 | 167 | for _, info := range bs.locales { 168 | if branch == info.Short { 169 | return info 170 | } 171 | } 172 | return nil 173 | } 174 | 175 | // flags for search branches 176 | const ( 177 | BrSearchLocal uint8 = 1 178 | BrSearchRemote uint8 = 1 << 1 179 | BrSearchAll = BrSearchLocal | BrSearchRemote 180 | ) 181 | 182 | // Search branches by name. 183 | // 184 | // TIP: recommend use `SearchV2()` for search branches. 185 | // 186 | // Usage: 187 | // 188 | // Search("fea", BrSearchLocal) 189 | // Search("fea", BrSearchAll) 190 | // // search on remotes 191 | // Search("fea", BrSearchRemote) 192 | // // search on remotes and remote name must be equals "origin" 193 | // Search("origin:fea", BrSearchRemote) 194 | func (bs *BranchInfos) Search(name string, flag uint8) []*BranchInfo { 195 | var list []*BranchInfo 196 | 197 | name = strings.TrimSpace(name) 198 | if len(name) == 0 { 199 | return list 200 | } 201 | 202 | var remote string 203 | // "remote name" - search on the remote 204 | if strings.Contains(name, ":") { 205 | remote, name = strutil.MustCut(name, ":") 206 | } 207 | 208 | if remote == "" && flag&BrSearchLocal == BrSearchLocal { 209 | for _, info := range bs.locales { 210 | if strings.Contains(info.Short, name) { 211 | list = append(list, info) 212 | } 213 | } 214 | } 215 | 216 | if flag&BrSearchRemote == BrSearchRemote { 217 | for _, info := range bs.remotes { 218 | if strings.Contains(info.Short, name) { 219 | if remote == "" { 220 | list = append(list, info) 221 | } else if remote == info.Remote { 222 | list = append(list, info) 223 | } 224 | } 225 | } 226 | } 227 | 228 | return list 229 | } 230 | 231 | // SearchOpt for search branches 232 | type SearchOpt struct { 233 | // Flag search flag, default is BrSearchLocal. 234 | Flag uint8 235 | Limit int 236 | // Remote name, on which remote to search. 237 | Remote string 238 | // Before search callback, return false to skip. 239 | Before func(bi *BranchInfo) bool 240 | } 241 | 242 | // SearchV2 search branches by matcher and hook func. 243 | // 244 | // Usage: 245 | // 246 | // SearchV2(brinfo.NewContainsMatch("fea"), &SearchOpt{}) 247 | // // use multi matcher 248 | // SearchV2(brinfo.QuickMulti("start:fea","glob:fea*"), &SearchOpt{}) 249 | func (bs *BranchInfos) SearchV2(matcher brinfo.BranchMatcher, opt *SearchOpt) []*BranchInfo { 250 | if opt == nil { 251 | opt = &SearchOpt{Limit: 10} 252 | } 253 | if opt.Flag == 0 { 254 | opt.Flag = basefn.OrValue(opt.Remote == "", BrSearchLocal, BrSearchRemote) 255 | } 256 | 257 | var list []*BranchInfo 258 | 259 | if opt.Flag&BrSearchLocal == BrSearchLocal { 260 | for _, info := range bs.locales { 261 | if opt.Before != nil && !opt.Before(info) { 262 | continue 263 | } 264 | 265 | if matcher.Match(info.Short) { 266 | list = append(list, info) 267 | if opt.Limit > 0 && len(list) >= opt.Limit { 268 | break 269 | } 270 | } 271 | } 272 | } 273 | 274 | if opt.Flag&BrSearchRemote == BrSearchRemote { 275 | for _, info := range bs.remotes { 276 | if opt.Remote != "" && opt.Remote != info.Remote { 277 | continue 278 | } 279 | 280 | if opt.Before != nil && !opt.Before(info) { 281 | continue 282 | } 283 | 284 | if matcher.Match(info.Short) { 285 | list = append(list, info) 286 | if opt.Limit > 0 && len(list) >= opt.Limit { 287 | break 288 | } 289 | } 290 | } 291 | } 292 | 293 | return list 294 | } 295 | 296 | // BrLines get 297 | func (bs *BranchInfos) BrLines() []string { 298 | return bs.brLines 299 | } 300 | 301 | // LastErr get 302 | func (bs *BranchInfos) LastErr() error { 303 | return bs.err 304 | } 305 | 306 | // SetBrLines for parse. 307 | func (bs *BranchInfos) SetBrLines(brLines []string) { 308 | bs.brLines = brLines 309 | } 310 | 311 | // Current branch 312 | func (bs *BranchInfos) Current() *BranchInfo { 313 | return bs.current 314 | } 315 | 316 | // Locales branches 317 | func (bs *BranchInfos) Locales() []*BranchInfo { 318 | return bs.locales 319 | } 320 | 321 | // Remotes branch infos get 322 | // 323 | // if remote="", will return all remote branches 324 | func (bs *BranchInfos) Remotes(remote string) []*BranchInfo { 325 | if remote == "" { 326 | return bs.remotes 327 | } 328 | 329 | ls := make([]*BranchInfo, 0) 330 | for _, info := range bs.remotes { 331 | if info.Remote == remote { 332 | ls = append(ls, info) 333 | } 334 | } 335 | return ls 336 | } 337 | 338 | // All branches list 339 | func (bs *BranchInfos) All() []*BranchInfo { 340 | ls := make([]*BranchInfo, 0, len(bs.locales)+len(bs.remotes)) 341 | for _, info := range bs.locales { 342 | ls = append(ls, info) 343 | } 344 | 345 | for _, info := range bs.remotes { 346 | ls = append(ls, info) 347 | } 348 | return ls 349 | } 350 | -------------------------------------------------------------------------------- /info_remote.go: -------------------------------------------------------------------------------- 1 | package gitw 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gookit/goutil/mathutil" 7 | ) 8 | 9 | // remote type names 10 | const ( 11 | RemoteTypePush = "push" 12 | RemoteTypeFetch = "fetch" 13 | ) 14 | 15 | // RemoteInfos map. key is type name(see RemoteTypePush) 16 | type RemoteInfos map[string]*RemoteInfo 17 | 18 | // FetchInfo fetch remote info 19 | func (rs RemoteInfos) FetchInfo() *RemoteInfo { 20 | return rs[RemoteTypeFetch] 21 | } 22 | 23 | // PushInfo push remote info 24 | func (rs RemoteInfos) PushInfo() *RemoteInfo { 25 | return rs[RemoteTypePush] 26 | } 27 | 28 | // RemoteInfo struct 29 | // 30 | // - http: "https://github.com/gookit/gitw.git" 31 | // - git: "git@github.com:gookit/gitw.git" 32 | type RemoteInfo struct { 33 | // Name the repo remote name, default see DefaultRemoteName 34 | Name string 35 | // Type remote type. allow: push, fetch 36 | Type string 37 | // URL full git remote URL string. 38 | // 39 | // eg: 40 | // - http: "https://github.com/gookit/gitw.git" 41 | // - git: "git@github.com:gookit/gitw.git" 42 | URL string 43 | 44 | // ---- details 45 | 46 | // Scheme the url scheme. eg: git, http, https 47 | Scheme string 48 | // Host name. eg: "github.com" 49 | Host string 50 | Port int 51 | // the group, repo name 52 | Group, Repo string 53 | 54 | // Proto the type 'ssh' OR 'http' 55 | Proto string 56 | } 57 | 58 | // NewRemoteInfo create 59 | func NewRemoteInfo(name, url, typ string) (*RemoteInfo, error) { 60 | r := &RemoteInfo{ 61 | Name: name, 62 | URL: url, 63 | Type: typ, 64 | } 65 | 66 | if err := ParseRemoteURL(url, r); err != nil { 67 | return nil, err 68 | } 69 | return r, nil 70 | } 71 | 72 | // NewEmptyRemoteInfo only with URL string. 73 | func NewEmptyRemoteInfo(URL string) *RemoteInfo { 74 | return &RemoteInfo{ 75 | Name: DefaultRemoteName, 76 | URL: URL, 77 | Type: RemoteTypePush, 78 | } 79 | } 80 | 81 | // Valid check 82 | func (r *RemoteInfo) Valid() bool { 83 | return r.URL != "" 84 | } 85 | 86 | // Invalid check 87 | func (r *RemoteInfo) Invalid() bool { 88 | return r.URL == "" 89 | } 90 | 91 | // GitURL build. eg: "git@github.com:gookit/gitw.git" 92 | func (r *RemoteInfo) GitURL() string { 93 | return SchemeGIT + "@" + r.Host + ":" + r.Group + "/" + r.Repo + ".git" 94 | } 95 | 96 | // RawURLOfHTTP get remote url, if RemoteInfo.URL is git proto, build an HTTPS url. 97 | func (r *RemoteInfo) RawURLOfHTTP() string { 98 | return r.URLOrBuild() 99 | } 100 | 101 | // URLOrBuild get remote HTTP url, if RemoteInfo.URL is git proto, build an HTTPS url. 102 | func (r *RemoteInfo) URLOrBuild() string { 103 | if r.Proto == ProtoHTTP { 104 | return r.URL 105 | } 106 | return r.buildHTTPURL(true) 107 | } 108 | 109 | // URLOfHTTP build an HTTP url. 110 | func (r *RemoteInfo) URLOfHTTP() string { 111 | return r.buildHTTPURL(false) 112 | } 113 | 114 | // URLOfHTTPS build an HTTPS url. 115 | func (r *RemoteInfo) URLOfHTTPS() string { 116 | return r.buildHTTPURL(true) 117 | } 118 | 119 | // URLOfHTTPS build an HTTP(S) url. 120 | func (r *RemoteInfo) buildHTTPURL(toHttps bool) string { 121 | schema := SchemeHTTP 122 | if toHttps { 123 | schema = SchemeHTTPS 124 | } 125 | 126 | return schema + "://" + r.Host + "/" + r.Group + "/" + r.Repo 127 | } 128 | 129 | // HTTPHost URL build. return like: https://github.com 130 | func (r *RemoteInfo) HTTPHost(disableHttps ...bool) string { 131 | if len(disableHttps) > 0 && disableHttps[0] { 132 | return SchemeHTTP + "://" + r.Host 133 | } 134 | 135 | schema := r.Scheme 136 | if r.Proto != ProtoHTTP { 137 | schema = SchemeHTTPS 138 | } 139 | 140 | return schema + "://" + r.Host 141 | } 142 | 143 | // Path string 144 | func (r *RemoteInfo) Path() string { 145 | return r.RepoPath() 146 | } 147 | 148 | // RepoPath string 149 | func (r *RemoteInfo) RepoPath() string { 150 | return r.Group + "/" + r.Repo 151 | } 152 | 153 | // String remote info to string. 154 | func (r *RemoteInfo) String() string { 155 | return fmt.Sprintf("%s %s (%s)", r.Name, r.URL, r.Type) 156 | } 157 | 158 | // HostWithPort host with port 159 | func (r *RemoteInfo) HostWithPort() string { 160 | return r.Host + ":" + mathutil.String(r.Port) 161 | } 162 | -------------------------------------------------------------------------------- /info_status.go: -------------------------------------------------------------------------------- 1 | package gitw 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/gookit/goutil/strutil" 8 | ) 9 | 10 | // StatusPattern string. eg: master...origin/master 11 | const StatusPattern = `^([\w-]+)...([\w-]+)/(\w[\w/-]+)$` 12 | 13 | var statusRegex = regexp.MustCompile(StatusPattern) 14 | 15 | // StatusInfo struct 16 | // 17 | // by run: git status -bs -u 18 | type StatusInfo struct { 19 | // Branch current branch name. 20 | Branch string 21 | // UpRemote current upstream remote name. 22 | UpRemote string 23 | // UpBranch current upstream remote branch name. 24 | UpBranch string 25 | 26 | fileNum int 27 | 28 | // Deleted files 29 | Deleted []string 30 | // Renamed files, contains RM(rename and modify) files 31 | Renamed []string 32 | // Modified files 33 | Modified []string 34 | // Unstacked new created files. 35 | Unstacked []string 36 | } 37 | 38 | // NewStatusInfo from string. 39 | func NewStatusInfo(str string) *StatusInfo { 40 | si := &StatusInfo{} 41 | return si.FromString(str) 42 | } 43 | 44 | // FromString parse and load info 45 | func (si *StatusInfo) FromString(str string) *StatusInfo { 46 | return si.FromLines(strings.Split(str, "\n")) 47 | } 48 | 49 | // FromLines parse and load info 50 | func (si *StatusInfo) FromLines(lines []string) *StatusInfo { 51 | for _, line := range lines { 52 | line = strings.Trim(line, " \t") 53 | if len(line) == 0 { 54 | continue 55 | } 56 | 57 | // files 58 | mark, value := strutil.MustCut(line, " ") 59 | switch mark { 60 | case "##": 61 | ss := statusRegex.FindStringSubmatch(value) 62 | if len(ss) > 1 { 63 | si.Branch, si.UpRemote, si.UpBranch = ss[1], ss[2], ss[3] 64 | } 65 | case "D": 66 | si.fileNum++ 67 | si.Deleted = append(si.Deleted, value) 68 | case "R": 69 | si.fileNum++ 70 | si.Renamed = append(si.Renamed, value) 71 | case "M": 72 | si.fileNum++ 73 | si.Modified = append(si.Modified, value) 74 | case "RM": // rename and modify 75 | si.fileNum++ 76 | si.Renamed = append(si.Renamed, value) 77 | case "??": 78 | si.fileNum++ 79 | si.Unstacked = append(si.Unstacked, value) 80 | } 81 | } 82 | return si 83 | } 84 | 85 | // FileNum in git status 86 | func (si *StatusInfo) FileNum() int { 87 | return si.fileNum 88 | } 89 | 90 | // IsCleaned status in workspace 91 | func (si *StatusInfo) IsCleaned() bool { 92 | return si.fileNum == 0 93 | } 94 | -------------------------------------------------------------------------------- /info_test.go: -------------------------------------------------------------------------------- 1 | package gitw_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/gookit/gitw" 8 | "github.com/gookit/gitw/brinfo" 9 | "github.com/gookit/goutil/dump" 10 | "github.com/gookit/goutil/testutil/assert" 11 | ) 12 | 13 | func TestNewRemoteInfo(t *testing.T) { 14 | URL := "https://github.com/gookit/gitw" 15 | 16 | rt, err := gitw.NewRemoteInfo("origin", URL, gitw.RemoteTypePush) 17 | assert.NoErr(t, err) 18 | assert.True(t, rt.Valid()) 19 | assert.False(t, rt.Invalid()) 20 | assert.Eq(t, "origin", rt.Name) 21 | assert.Eq(t, gitw.RemoteTypePush, rt.Type) 22 | assert.Eq(t, "github.com", rt.Host) 23 | assert.Eq(t, "gookit/gitw", rt.RepoPath()) 24 | assert.Eq(t, gitw.SchemeHTTPS, rt.Scheme) 25 | assert.Eq(t, gitw.ProtoHTTP, rt.Proto) 26 | assert.Eq(t, rt.URL, rt.RawURLOfHTTP()) 27 | 28 | URL = "git@github.com:gookit/gitw.git" 29 | rt, err = gitw.NewRemoteInfo("origin", URL, gitw.RemoteTypePush) 30 | assert.NoErr(t, err) 31 | assert.Eq(t, "github.com", rt.Host) 32 | assert.Eq(t, "gookit/gitw", rt.Path()) 33 | assert.Eq(t, gitw.SchemeGIT, rt.Scheme) 34 | assert.Eq(t, gitw.ProtoSSH, rt.Proto) 35 | assert.Eq(t, "https://github.com/gookit/gitw", rt.RawURLOfHTTP()) 36 | } 37 | 38 | func TestNewRemoteInfo_sshAndGit(t *testing.T) { 39 | URL := "ssh://git@github.com:gookit/gitw.git" 40 | rt, err := gitw.NewRemoteInfo("origin", URL, gitw.RemoteTypePush) 41 | assert.NoErr(t, err) 42 | assert.Eq(t, "github.com", rt.Host) 43 | assert.Eq(t, "gookit/gitw", rt.Path()) 44 | assert.Eq(t, gitw.SchemeGIT, rt.Scheme) 45 | assert.Eq(t, gitw.ProtoSSH, rt.Proto) 46 | assert.Eq(t, "https://github.com/gookit/gitw", rt.RawURLOfHTTP()) 47 | } 48 | 49 | func TestNewRemoteInfo_sshAndGit_port(t *testing.T) { 50 | URL := "ssh://git@github.com:3455/gookit/gitw.git" 51 | // URL := "git@github.com:3455/gookit/gitw.git" 52 | // info, err := url.Parse(URL) 53 | // dump.P(info) 54 | 55 | rt, err := gitw.NewRemoteInfo("origin", URL, gitw.RemoteTypePush) 56 | dump.P(rt) 57 | assert.NoErr(t, err) 58 | assert.Eq(t, "github.com", rt.Host) 59 | assert.Eq(t, "github.com:3455", rt.HostWithPort()) 60 | assert.Eq(t, "gookit/gitw", rt.Path()) 61 | assert.Eq(t, gitw.SchemeGIT, rt.Scheme) 62 | assert.Eq(t, gitw.ProtoSSH, rt.Proto) 63 | assert.Eq(t, "https://github.com/gookit/gitw", rt.RawURLOfHTTP()) 64 | } 65 | 66 | func TestParseBranchLine_simple(t *testing.T) { 67 | info, err := gitw.ParseBranchLine("* ", false) 68 | assert.Err(t, err) 69 | 70 | info, err = gitw.ParseBranchLine("* (HEAD)", false) 71 | assert.Err(t, err) 72 | 73 | info, err = gitw.ParseBranchLine("* fea/new_br001", false) 74 | assert.NoErr(t, err) 75 | 76 | assert.True(t, info.Current) 77 | assert.True(t, info.IsValid()) 78 | assert.False(t, info.IsRemoted()) 79 | assert.Eq(t, "", info.Remote) 80 | assert.Eq(t, "fea/new_br001", info.Name) 81 | assert.Eq(t, "fea/new_br001", info.Short) 82 | 83 | info, err = gitw.ParseBranchLine(" remotes/source/my_new_br ", false) 84 | assert.NoErr(t, err) 85 | 86 | assert.False(t, info.Current) 87 | assert.True(t, info.IsValid()) 88 | assert.True(t, info.IsRemoted()) 89 | assert.Eq(t, "source", info.Remote) 90 | assert.Eq(t, "remotes/source/my_new_br", info.Name) 91 | assert.Eq(t, "my_new_br", info.Short) 92 | } 93 | 94 | func TestParseBranchLine_verbose(t *testing.T) { 95 | info, err := gitw.ParseBranchLine("* fea/new_br001 73j824d the message 001", true) 96 | assert.NoErr(t, err) 97 | 98 | assert.True(t, info.Current) 99 | assert.True(t, info.IsValid()) 100 | assert.False(t, info.IsRemoted()) 101 | assert.Eq(t, "", info.Remote) 102 | assert.Eq(t, "fea/new_br001", info.Name) 103 | assert.Eq(t, "fea/new_br001", info.Short) 104 | assert.Eq(t, "73j824d", info.Hash) 105 | assert.Eq(t, "the message 001", info.HashMsg) 106 | 107 | info, err = gitw.ParseBranchLine(" remotes/source/my_new_br 6fb8dcd the message 003 ", true) 108 | assert.NoErr(t, err) 109 | dump.P(info) 110 | 111 | assert.False(t, info.Current) 112 | assert.True(t, info.IsValid()) 113 | assert.True(t, info.IsRemoted()) 114 | assert.Eq(t, "source", info.Remote) 115 | assert.Eq(t, "remotes/source/my_new_br", info.Name) 116 | assert.Eq(t, "my_new_br", info.Short) 117 | assert.Eq(t, "6fb8dcd", info.Hash) 118 | assert.Eq(t, "the message 003", info.HashMsg) 119 | 120 | info, err = gitw.ParseBranchLine("* (头指针在 v0.2.3 分离) 3c08adf chore: update readme add branch info docs", true) 121 | assert.Err(t, err) 122 | info, err = gitw.ParseBranchLine("* (HEAD detached at pull/29/merge) 62f3455 Merge cfc79b748e176c1c9e266c8bc413c87fe974acef into c9503c2aef993a2cf582d90c137deda53c9bca68", true) 123 | assert.Err(t, err) 124 | } 125 | 126 | func TestBranchInfo_parse_simple(t *testing.T) { 127 | gitOut := ` 128 | fea/new_br001 129 | * master 130 | my_new_br 131 | remotes/origin/my_new_br 132 | remotes/source/my_new_br 133 | ` 134 | bis := gitw.NewBranchInfos(gitOut) 135 | bis.Parse() 136 | // dump.P(bis) 137 | 138 | assert.NoErr(t, bis.LastErr()) 139 | assert.NotEmpty(t, bis.Current()) 140 | assert.NotEmpty(t, bis.Locales()) 141 | assert.NotEmpty(t, bis.Remotes("")) 142 | assert.Eq(t, "master", bis.Current().Name) 143 | } 144 | 145 | func TestBranchInfo_parse_invalid(t *testing.T) { 146 | gitOut := ` 147 | fea/new_br001 148 | * (HEAD) 149 | my_new_br 150 | remotes/origin/my_new_br 151 | ` 152 | bis := gitw.NewBranchInfos(gitOut) 153 | bis.Parse() 154 | // dump.P(bis) 155 | 156 | assert.Err(t, bis.LastErr()) 157 | assert.Nil(t, bis.Current()) 158 | assert.NotEmpty(t, bis.Locales()) 159 | assert.NotEmpty(t, bis.Remotes("origin")) 160 | } 161 | 162 | func TestBranchInfo_parse_verbose(t *testing.T) { 163 | gitOut := ` 164 | fea/new_br001 73j824d the message 001 165 | * master 7r60d4f the message 002 166 | my_new_br 6fb8dcd the message 003 167 | remotes/origin/my_new_br 6fb8dcd the message 003 168 | remotes/source/my_new_br 6fb8dcd the message 003 169 | ` 170 | 171 | bis := gitw.EmptyBranchInfos() 172 | bis.SetBrLines(strings.Split(strings.TrimSpace(gitOut), "\n")) 173 | bis.Parse() 174 | // dump.P(bis) 175 | 176 | assert.NoErr(t, bis.LastErr()) 177 | assert.NotEmpty(t, bis.Current()) 178 | assert.NotEmpty(t, bis.Locales()) 179 | assert.NotEmpty(t, bis.Remotes("")) 180 | assert.Eq(t, "master", bis.Current().Name) 181 | assert.True(t, bis.HasLocal("fea/new_br001")) 182 | 183 | t.Run("BranchInfos search", func(t *testing.T) { 184 | testBranchInfosSearch(bis, t) 185 | }) 186 | 187 | t.Run("BranchInfos searchV2", func(t *testing.T) { 188 | testBranchInfosSearchV2(bis, t) 189 | }) 190 | } 191 | 192 | func testBranchInfosSearch(bis *gitw.BranchInfos, t *testing.T) { 193 | // search 194 | rets := bis.Search("new", gitw.BrSearchLocal) 195 | assert.NotEmpty(t, rets) 196 | assert.Len(t, rets, 2) 197 | 198 | // search 199 | rets = bis.Search("new", gitw.BrSearchRemote) 200 | assert.NotEmpty(t, rets) 201 | assert.Len(t, rets, 2) 202 | 203 | // search 204 | rets = bis.Search("origin:new", gitw.BrSearchRemote) 205 | assert.NotEmpty(t, rets) 206 | assert.Len(t, rets, 1) 207 | assert.True(t, rets[0].IsRemoted()) 208 | assert.Eq(t, "origin", rets[0].Remote) 209 | } 210 | 211 | func testBranchInfosSearchV2(bis *gitw.BranchInfos, t *testing.T) { 212 | // search v2 use glob 213 | mch := brinfo.NewGlobMatch("*new*") 214 | opt := &gitw.SearchOpt{Limit: 5, Flag: gitw.BrSearchAll} 215 | rets := bis.SearchV2(mch, opt) 216 | assert.Len(t, rets, 4) 217 | 218 | // search v2 use glob on local 219 | opt.Flag = gitw.BrSearchLocal 220 | mch = brinfo.NewGlobMatch("*new*") 221 | rets = bis.SearchV2(mch, opt) 222 | assert.Len(t, rets, 2) 223 | 224 | // search v2 use contains 225 | mch = brinfo.NewContainsMatch("new") 226 | rets = bis.SearchV2(mch, opt) 227 | assert.Len(t, rets, 2) 228 | 229 | // search v2 use prefix 230 | mch = brinfo.NewPrefixMatch("my") 231 | rets = bis.SearchV2(mch, opt) 232 | assert.Len(t, rets, 1) 233 | 234 | // search v2 use suffix 235 | opt.Flag = gitw.BrSearchLocal 236 | mch = brinfo.NewSuffixMatch("new_br") 237 | rets = bis.SearchV2(mch, opt) 238 | assert.Len(t, rets, 1) 239 | 240 | } 241 | 242 | func TestStatusInfo_FromLines(t *testing.T) { 243 | text := ` 244 | ## master...origin/fea/master 245 | RM app/Common/GitLocal/GitFlow.php -> app/Common/GitLocal/GitFactory.php 246 | M app/Common/GitLocal/GitHub.php 247 | ?? app/Common/GitLocal/GitConst.php 248 | D tmp/delete-some.file 249 | ` 250 | si := gitw.NewStatusInfo(text) 251 | 252 | dump.P(si) 253 | assert.Eq(t, "master", si.Branch) 254 | assert.Eq(t, "origin", si.UpRemote) 255 | assert.Eq(t, "fea/master", si.UpBranch) 256 | assert.False(t, si.IsCleaned()) 257 | assert.Gt(t, si.FileNum(), 2) 258 | } 259 | -------------------------------------------------------------------------------- /info_util.go: -------------------------------------------------------------------------------- 1 | package gitw 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | 7 | "github.com/gookit/gitw/gitutil" 8 | "github.com/gookit/goutil/errorx" 9 | "github.com/gookit/goutil/mathutil" 10 | "github.com/gookit/goutil/strutil" 11 | ) 12 | 13 | // ErrRemoteInfoNil error 14 | var ErrRemoteInfoNil = errorx.Raw("the remote info data cannot be nil") 15 | 16 | // ParseRemoteURL info to the RemoteInfo object. 17 | func ParseRemoteURL(URL string, r *RemoteInfo) (err error) { 18 | if r == nil { 19 | return ErrRemoteInfoNil 20 | } 21 | 22 | var str string 23 | hasSfx := strings.HasSuffix(URL, ".git") 24 | 25 | // eg: "git@github.com:gookit/gitw.git" 26 | if gitutil.IsSSHProto(URL) { 27 | r.Proto = ProtoSSH 28 | URL = strings.TrimPrefix(URL, "ssh://") 29 | if hasSfx { 30 | str = URL[4 : len(URL)-4] 31 | } else { 32 | str = URL[4:] 33 | } 34 | 35 | host, path, ok := strutil.Cut(str, ":") 36 | if !ok { 37 | return errorx.Rawf("invalid git URL: %s", URL) 38 | } 39 | 40 | var group, repo string 41 | nodes := strings.Split(path, "/") 42 | if len(nodes) < 2 { 43 | return errorx.Rawf("invalid git URL path: %s", path) 44 | } 45 | 46 | // check first is port 47 | if len(nodes) > 2 { 48 | if strutil.IsNumeric(nodes[0]) { 49 | r.Port = mathutil.SafeInt(nodes[0]) 50 | group = nodes[1] 51 | repo = strings.Join(nodes[2:], "/") 52 | } 53 | } else { 54 | group, repo, ok = strutil.Cut(path, "/") 55 | if !ok { 56 | return errorx.Rawf("invalid git URL path: %s", path) 57 | } 58 | } 59 | 60 | r.Scheme = SchemeGIT 61 | r.Host, r.Group, r.Repo = host, group, repo 62 | return nil 63 | } 64 | 65 | // http protocol 66 | str = URL 67 | if hasSfx { 68 | str = URL[0 : len(URL)-4] 69 | } 70 | 71 | // eg: "https://github.com/gookit/gitw.git" 72 | info, err := url.Parse(str) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | group, repo, ok := strutil.Cut(strings.Trim(info.Path, "/"), "/") 78 | if !ok { 79 | return errorx.Rawf("invalid http URL path: %s", info.Path) 80 | } 81 | 82 | r.Proto = ProtoHTTP 83 | r.Scheme = info.Scheme 84 | r.Host, r.Group, r.Repo = info.Host, group, repo 85 | return nil 86 | } 87 | 88 | // ErrInvalidBrLine error 89 | var ErrInvalidBrLine = errorx.Raw("invalid git branch line text") 90 | 91 | // ParseBranchLine to BranchInfo data 92 | // 93 | // verbose: 94 | // 95 | // False - only branch name 96 | // True - get by `git br -v --all` 97 | // format: * BRANCH_NAME COMMIT_ID COMMIT_MSG 98 | func ParseBranchLine(line string, verbose bool) (*BranchInfo, error) { 99 | info := &BranchInfo{} 100 | line = strings.TrimSpace(line) 101 | 102 | if strings.HasPrefix(line, "*") { 103 | info.Current = true 104 | line = strings.Trim(line, "*\t ") 105 | } 106 | 107 | if line == "" { 108 | return nil, ErrInvalidBrLine 109 | } 110 | 111 | // at tag head. eg: `* (头指针在 v0.2.3 分离) 3c08adf chore: update readme add branch info docs` 112 | if strings.HasPrefix(line, "(") || strings.HasPrefix(line, "(") { 113 | return nil, ErrInvalidBrLine 114 | } 115 | 116 | if !verbose { 117 | info.SetName(line) 118 | return info, nil 119 | } 120 | 121 | // parse name 122 | nodes := strutil.SplitNTrimmed(line, " ", 2) 123 | if len(nodes) != 2 { 124 | return nil, ErrInvalidBrLine 125 | } 126 | 127 | info.SetName(nodes[0]) 128 | 129 | // parse hash and message 130 | nodes = strutil.SplitNTrimmed(nodes[1], " ", 2) 131 | if len(nodes) != 2 { 132 | return nil, ErrInvalidBrLine 133 | } 134 | 135 | info.Hash, info.HashMsg = nodes[0], nodes[1] 136 | return info, nil 137 | } 138 | 139 | func isVerboseBranchLine(line string) bool { 140 | line = strings.Trim(line, " *\t\n\r\x0B") 141 | return strings.ContainsRune(line, ' ') 142 | } 143 | -------------------------------------------------------------------------------- /repo.go: -------------------------------------------------------------------------------- 1 | package gitw 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gookit/gitw/brinfo" 7 | "github.com/gookit/goutil/arrutil" 8 | "github.com/gookit/goutil/errorx" 9 | "github.com/gookit/goutil/fsutil" 10 | "github.com/gookit/goutil/maputil" 11 | "github.com/gookit/goutil/strutil" 12 | "github.com/gookit/goutil/sysutil/cmdr" 13 | ) 14 | 15 | const ( 16 | cacheRemoteNames = "rmtNames" 17 | cacheRemoteInfos = "rmtInfos" 18 | cacheLastCommitID = "lastCID" 19 | cacheCurrentBranch = "curBranch" 20 | cacheMaxTagVersion = "maxVersion" 21 | cacheUpstreamPath = "upstreamTo" 22 | ) 23 | 24 | // RepoConfig struct 25 | type RepoConfig struct { 26 | // DefaultBranch name, default is DefaultBranchName 27 | DefaultBranch string 28 | // DefaultRemote name, default is DefaultRemoteName 29 | DefaultRemote string 30 | } 31 | 32 | func newDefaultCfg() *RepoConfig { 33 | return &RepoConfig{ 34 | DefaultBranch: DefaultBranchName, 35 | DefaultRemote: DefaultRemoteName, 36 | } 37 | } 38 | 39 | // Repo struct 40 | type Repo struct { 41 | gw *GitWrap 42 | // the repo dir 43 | dir string 44 | // save last error 45 | err error 46 | // config 47 | cfg *RepoConfig 48 | 49 | // status info 50 | statusInfo *StatusInfo 51 | 52 | // branch infos for the repo 53 | branchInfos *BranchInfos 54 | 55 | // remoteNames 56 | remoteNames []string 57 | // remoteInfosMp 58 | // 59 | // Example: 60 | // {origin: {fetch: remote info, push: remote info}} 61 | remoteInfosMp map[string]RemoteInfos 62 | 63 | // cache some information of the repo 64 | cache maputil.Data 65 | } 66 | 67 | // NewRepo create Repo object 68 | func NewRepo(dir string) *Repo { 69 | return &Repo{ 70 | dir: dir, 71 | cfg: newDefaultCfg(), 72 | // init gw 73 | gw: NewWithWorkdir(dir), 74 | // cache some information 75 | cache: make(maputil.Data, 8), 76 | } 77 | } 78 | 79 | // WithFn new repo self config func 80 | func (r *Repo) WithFn(fn func(r *Repo)) *Repo { 81 | fn(r) 82 | return r 83 | } 84 | 85 | // WithConfig new repo config 86 | func (r *Repo) WithConfig(cfg *RepoConfig) *Repo { 87 | r.cfg = cfg 88 | return r 89 | } 90 | 91 | // WithConfigFn new repo config func 92 | func (r *Repo) WithConfigFn(fn func(cfg *RepoConfig)) *Repo { 93 | fn(r.cfg) 94 | return r 95 | } 96 | 97 | // PrintCmdOnExec settings. 98 | func (r *Repo) PrintCmdOnExec() *Repo { 99 | r.gw.BeforeExec = PrintCmdline 100 | return r 101 | } 102 | 103 | // SetDryRun settings. 104 | func (r *Repo) SetDryRun(dr bool) *Repo { 105 | r.gw.DryRun = dr 106 | return r 107 | } 108 | 109 | // Init run git init for the repo dir. 110 | func (r *Repo) Init() error { 111 | return r.gw.Init().Run() 112 | } 113 | 114 | // IsInited is init git repo dir 115 | func (r *Repo) IsInited() bool { 116 | return r.gw.IsGitRepo() 117 | } 118 | 119 | // Info get repo information 120 | func (r *Repo) Info() *RepoInfo { 121 | ri := &RepoInfo{ 122 | Dir: r.dir, 123 | Name: fsutil.Name(r.dir), 124 | // more 125 | Branch: r.CurBranchName(), 126 | Version: r.LargestTag(), 127 | LastHash: r.LastAbbrevID(), 128 | Upstream: r.UpstreamPath(), 129 | } 130 | 131 | rt := r.loadRemoteInfos().FirstRemoteInfo() 132 | if rt == nil { 133 | return ri 134 | } 135 | 136 | ri.Name = rt.Repo 137 | ri.Path = rt.Path() 138 | ri.URL = rt.URLOrBuild() 139 | 140 | remotes := make(map[string]string) 141 | for name, infos := range r.remoteInfosMp { 142 | remotes[name] = infos.FetchInfo().URL 143 | } 144 | 145 | ri.Remotes = remotes 146 | return ri 147 | } 148 | 149 | // FetchAll fetch all remote branches 150 | func (r *Repo) FetchAll(args ...string) error { 151 | return r.gw.Cmd("fetch", "--all").AddArgs(args).Run() 152 | } 153 | 154 | // ------------------------------------------------- 155 | // repo tags 156 | // ------------------------------------------------- 157 | 158 | // ShaHead keywords 159 | const ShaHead = "HEAD" 160 | 161 | // some special keywords for match tag 162 | const ( 163 | TagLast = "last" 164 | TagPrev = "prev" 165 | TagHead = "head" 166 | ) 167 | 168 | // enum type value constants for fetch tags 169 | const ( 170 | RefNameTagType int = iota 171 | CreatorDateTagType 172 | DescribeTagType 173 | ) 174 | 175 | // AutoMatchTag by given sha or tag name 176 | func (r *Repo) AutoMatchTag(sha string) string { 177 | return r.AutoMatchTagByType(sha, RefNameTagType) 178 | } 179 | 180 | // AutoMatchTagByType by given sha or tag name. 181 | func (r *Repo) AutoMatchTagByType(sha string, tagType int) string { 182 | switch strings.ToLower(sha) { 183 | case TagLast: 184 | return r.LargestTagByTagType(tagType) 185 | case TagPrev: 186 | return r.TagSecondMaxByTagType(tagType) 187 | case TagHead: 188 | return ShaHead 189 | default: 190 | return sha 191 | } 192 | } 193 | 194 | // MaxTag get max tag version of the repo 195 | func (r *Repo) MaxTag() string { 196 | return r.LargestTag() 197 | } 198 | 199 | // LargestTag get max tag version of the repo 200 | func (r *Repo) LargestTag() string { 201 | tagVer := r.cache.Str(cacheMaxTagVersion) 202 | if len(tagVer) > 0 { 203 | return tagVer 204 | } 205 | 206 | tags := r.TagsSortedByRefName() 207 | 208 | if len(tags) > 0 { 209 | r.cache.Set(cacheMaxTagVersion, tags[0]) 210 | return tags[0] 211 | } 212 | return "" 213 | } 214 | 215 | // LargestTagByTagType get max tag version of the repo by tag_type 216 | func (r *Repo) LargestTagByTagType(tagType int) string { 217 | tagVer := r.cache.Str(cacheMaxTagVersion) 218 | if len(tagVer) > 0 { 219 | return tagVer 220 | } 221 | 222 | tags := make([]string, 0, 2) 223 | switch tagType { 224 | case CreatorDateTagType: 225 | tags = append(tags, r.TagsSortedByCreatorDate()...) 226 | case DescribeTagType: 227 | tags = append(tags, r.TagByDescribe("")) 228 | default: 229 | tags = append(tags, r.TagsSortedByRefName()...) 230 | } 231 | 232 | if len(tags) > 0 { 233 | r.cache.Set(cacheMaxTagVersion, tags[0]) 234 | return tags[0] 235 | } 236 | return "" 237 | } 238 | 239 | // PrevMaxTag get second-largest tag of the repo 240 | func (r *Repo) PrevMaxTag() string { 241 | return r.TagSecondMax() 242 | } 243 | 244 | // TagSecondMax get second-largest tag of the repo 245 | func (r *Repo) TagSecondMax() string { 246 | tags := r.TagsSortedByRefName() 247 | 248 | if len(tags) > 1 { 249 | return tags[1] 250 | } 251 | return "" 252 | } 253 | 254 | // TagSecondMaxByTagType get second-largest tag of the repo by tag_type 255 | func (r *Repo) TagSecondMaxByTagType(tagType int) string { 256 | tags := make([]string, 0, 2) 257 | switch tagType { 258 | case CreatorDateTagType: 259 | tags = append(tags, r.TagsSortedByCreatorDate()...) 260 | case DescribeTagType: 261 | current := r.TagByDescribe("") 262 | if len(current) != 0 { 263 | tags = append(tags, current, r.TagByDescribe(current)) 264 | } else { 265 | tags = append(tags, current) 266 | } 267 | default: 268 | tags = append(tags, r.TagsSortedByRefName()...) 269 | } 270 | 271 | if len(tags) > 1 { 272 | return tags[1] 273 | } 274 | return "" 275 | } 276 | 277 | // TagsSortedByRefName get repo tags list 278 | func (r *Repo) TagsSortedByRefName() []string { 279 | str, err := r.gw.Tag("-l", "--sort=-version:refname").Output() 280 | if err != nil { 281 | r.setErr(err) 282 | return nil 283 | } 284 | 285 | return cmdr.OutputLines(str) 286 | } 287 | 288 | // TagsSortedByCreatorDate get repo tags list by creator date sort 289 | func (r *Repo) TagsSortedByCreatorDate() []string { 290 | str, err := r.gw. 291 | Tag("-l", "--sort=-creatordate", "--format=%(refname:strip=2)"). 292 | Output() 293 | 294 | if err != nil { 295 | r.setErr(err) 296 | return nil 297 | } 298 | return cmdr.OutputLines(str) 299 | } 300 | 301 | // TagByDescribe get tag by describe command. if current not empty, will exclude it. 302 | func (r *Repo) TagByDescribe(current string) (ver string) { 303 | var err error 304 | if len(current) == 0 { 305 | ver, err = r.gw.Describe("--tags", "--abbrev=0").Output() 306 | } else { 307 | ver, err = r.gw. 308 | Describe("--tags", "--abbrev=0"). 309 | Argf("tags/%s^", current). 310 | Output() 311 | } 312 | 313 | if err != nil { 314 | r.setErr(err) 315 | return "" 316 | } 317 | return cmdr.FirstLine(ver) 318 | } 319 | 320 | // Tags get repo tags list 321 | func (r *Repo) Tags() []string { 322 | ss, err := r.gw.Tag("-l").OutputLines() 323 | if err != nil { 324 | r.setErr(err) 325 | return nil 326 | } 327 | 328 | return ss 329 | } 330 | 331 | // ------------------------------------------------- 332 | // repo git log 333 | // ------------------------------------------------- 334 | 335 | // LastAbbrevID get last abbrev commit ID, len is 7 336 | func (r *Repo) LastAbbrevID() string { 337 | cid := r.LastCommitID() 338 | if cid == "" { 339 | return "" 340 | } 341 | 342 | return strutil.Substr(cid, 0, 7) 343 | } 344 | 345 | // LastCommitID value 346 | func (r *Repo) LastCommitID() string { 347 | lastCID := r.cache.Str(cacheLastCommitID) 348 | if len(lastCID) > 0 { 349 | return lastCID 350 | } 351 | 352 | // by: git log -1 --format='%H' 353 | lastCID, err := r.gw.Log("-1", "--format=%H").Output() 354 | if err != nil { 355 | r.setErr(err) 356 | return "" 357 | } 358 | 359 | r.cache.Set(cacheLastCommitID, lastCID) 360 | return lastCID 361 | } 362 | 363 | // ------------------------------------------------- 364 | // repo status 365 | // ------------------------------------------------- 366 | 367 | // StatusInfo get status info of the repo 368 | func (r *Repo) StatusInfo() *StatusInfo { 369 | if r.statusInfo == nil { 370 | r.statusInfo = &StatusInfo{} 371 | lines, err := r.gw.Status("-bs", "-u").OutputLines() 372 | if err != nil { 373 | r.setErr(err) 374 | return nil 375 | } 376 | 377 | r.statusInfo.FromLines(lines) 378 | } 379 | return r.statusInfo 380 | } 381 | 382 | // ------------------------------------------------- 383 | // repo branch 384 | // ------------------------------------------------- 385 | 386 | func (r *Repo) HasBranch(branch string, remote ...string) bool { 387 | return r.loadBranchInfos().branchInfos.IsExists(branch, remote...) 388 | } 389 | 390 | func (r *Repo) HasRemoteBranch(branch, remote string) bool { 391 | return r.loadBranchInfos().branchInfos.HasRemote(branch, remote) 392 | } 393 | 394 | func (r *Repo) HasLocalBranch(branch string) bool { 395 | return r.loadBranchInfos().branchInfos.HasLocal(branch) 396 | } 397 | 398 | // BranchInfos get branch infos of the repo 399 | func (r *Repo) BranchInfos() *BranchInfos { 400 | return r.loadBranchInfos().branchInfos 401 | } 402 | 403 | // ReloadBranches reload branch infos of the repo 404 | func (r *Repo) ReloadBranches() *BranchInfos { 405 | r.branchInfos = nil 406 | return r.loadBranchInfos().branchInfos 407 | } 408 | 409 | // CurBranchInfo get current branch info of the repo 410 | func (r *Repo) CurBranchInfo() *BranchInfo { 411 | return r.loadBranchInfos().branchInfos.Current() 412 | } 413 | 414 | // BranchInfo find branch info by name, if remote is empty, find local branch 415 | func (r *Repo) BranchInfo(branch string, remote ...string) *BranchInfo { 416 | return r.loadBranchInfos().branchInfos.GetByName(branch, remote...) 417 | } 418 | 419 | // SearchBranchV2 search branch infos by keywords 420 | func (r *Repo) SearchBranchV2(m brinfo.BranchMatcher, opt *SearchOpt) []*BranchInfo { 421 | return r.loadBranchInfos().branchInfos.SearchV2(m, opt) 422 | } 423 | 424 | // SearchBranches search branch infos by name 425 | func (r *Repo) SearchBranches(name string, flag uint8) []*BranchInfo { 426 | return r.loadBranchInfos().branchInfos.Search(name, flag) 427 | } 428 | 429 | // load branch infos 430 | func (r *Repo) loadBranchInfos() *Repo { 431 | // has loaded 432 | if r.branchInfos != nil { 433 | return r 434 | } 435 | 436 | str, err := r.gw.Branch("-v", "--all").Output() 437 | if err != nil { 438 | r.setErr(err) 439 | r.branchInfos = EmptyBranchInfos() 440 | return r 441 | } 442 | 443 | r.branchInfos = NewBranchInfos(str).Parse() 444 | return r 445 | } 446 | 447 | // HeadBranchName return current branch name 448 | func (r *Repo) HeadBranchName() string { return r.CurBranchName() } 449 | 450 | // CurBranchName return current branch name 451 | func (r *Repo) CurBranchName() string { 452 | brName := r.cache.Str(cacheCurrentBranch) 453 | if len(brName) > 0 { 454 | return brName 455 | } 456 | 457 | // cat .git/HEAD 458 | // OR 459 | // git branch --show-current // on high version git 460 | // OR 461 | // git symbolic-ref HEAD // out: refs/heads/fea_pref 462 | // git symbolic-ref --short -q HEAD // on checkout tag, run will error 463 | // Or 464 | // git rev-parse --abbrev-ref -q HEAD // on init project, will error 465 | 466 | str := r.gw.Branch("--show-current").SafeOutput() 467 | if len(str) == 0 { 468 | str, r.err = r.gw.RevParse("--abbrev-ref", "-q", "HEAD").Output() 469 | if r.err != nil { 470 | return "" 471 | } 472 | } 473 | 474 | // eg: fea_pref 475 | brName = cmdr.FirstLine(str) 476 | r.cache.Set(cacheCurrentBranch, brName) 477 | return brName 478 | } 479 | 480 | // SetUpstreamTo set the branch upstream remote branch. 481 | // If `localBranch` is empty, will use `branch` as `localBranch` 482 | // 483 | // CMD: 484 | // 485 | // git branch --set-upstream-to=/ 486 | func (r *Repo) SetUpstreamTo(remote, branch string, localBranch ...string) error { 487 | localBr := branch 488 | if len(localBranch) > 0 { 489 | localBr = localBranch[0] 490 | } 491 | 492 | return r.gw.Cmd("branch"). 493 | Argf("--set-upstream-to=%s/%s", remote, branch). 494 | AddArg(localBr). 495 | Run() 496 | } 497 | 498 | // BranchDelete handle 499 | func (r *Repo) BranchDelete(name string, remote string) error { 500 | if len(remote) > 0 { 501 | return r.gw.Push(remote, "--delete", name).Run() 502 | } 503 | return r.gw.Branch("-D", name).Run() 504 | } 505 | 506 | // ------------------------------------------------- 507 | // repo remote 508 | // ------------------------------------------------- 509 | 510 | // HasRemote check 511 | func (r *Repo) HasRemote(name string) bool { 512 | return arrutil.StringsHas(r.RemoteNames(), name) 513 | } 514 | 515 | // RemoteNames get 516 | func (r *Repo) RemoteNames() []string { 517 | return r.loadRemoteInfos().remoteNames 518 | } 519 | 520 | // RemoteLines get like: {origin: url, other: url} 521 | func (r *Repo) RemoteLines() map[string]string { 522 | remotes := make(map[string]string) 523 | for name, infos := range r.loadRemoteInfos().remoteInfosMp { 524 | remotes[name] = infos.FetchInfo().URL 525 | } 526 | 527 | return remotes 528 | } 529 | 530 | // UpstreamPath get current upstream remote and branch. 531 | // Returns like: origin/main 532 | // 533 | // CMD: 534 | // 535 | // git rev-parse --abbrev-ref @{u} 536 | func (r *Repo) UpstreamPath() string { 537 | path := r.cache.Str(cacheUpstreamPath) 538 | 539 | // RUN: git rev-parse --abbrev-ref @{u} 540 | if path == "" { 541 | path = r.Git().RevParse("--abbrev-ref", "@{u}").SafeOutput() 542 | r.cache.Set(cacheUpstreamPath, strings.TrimSpace(path)) 543 | } 544 | 545 | return path 546 | } 547 | 548 | // UpstreamRemote get current upstream remote name. 549 | func (r *Repo) UpstreamRemote() string { 550 | return strutil.OrHandle(r.UpstreamPath(), func(s string) string { 551 | remote, _ := strutil.QuietCut(s, "/") 552 | return remote 553 | }) 554 | } 555 | 556 | // UpstreamBranch get current upstream branch name. 557 | func (r *Repo) UpstreamBranch() string { 558 | return strutil.OrHandle(r.UpstreamPath(), func(s string) string { 559 | _, branch := strutil.QuietCut(s, "/") 560 | return branch 561 | }) 562 | } 563 | 564 | // RemoteInfos get by remote name 565 | func (r *Repo) RemoteInfos(remote string) RemoteInfos { 566 | r.loadRemoteInfos() 567 | 568 | if len(r.remoteInfosMp) == 0 { 569 | return nil 570 | } 571 | return r.remoteInfosMp[remote] 572 | } 573 | 574 | // DefaultRemoteInfo get 575 | func (r *Repo) DefaultRemoteInfo(typ ...string) *RemoteInfo { 576 | return r.RemoteInfo(r.cfg.DefaultRemote, typ...) 577 | } 578 | 579 | // FirstRemoteInfo get 580 | func (r *Repo) FirstRemoteInfo(typ ...string) *RemoteInfo { 581 | return r.RandomRemoteInfo(typ...) 582 | } 583 | 584 | // RandomRemoteInfo get 585 | func (r *Repo) RandomRemoteInfo(typ ...string) *RemoteInfo { 586 | r.loadRemoteInfos() 587 | 588 | if len(r.remoteNames) == 0 { 589 | return nil 590 | } 591 | return r.RemoteInfo(r.remoteNames[0], typ...) 592 | } 593 | 594 | // RemoteInfo get by remote name and type. 595 | // 596 | // - If remote is empty, will return default remote 597 | // - If typ is empty, will return random type info. 598 | // 599 | // Usage: 600 | // 601 | // ri := RemoteInfo("origin") 602 | // ri = RemoteInfo("origin", "push") 603 | func (r *Repo) RemoteInfo(remote string, typ ...string) *RemoteInfo { 604 | riMp := r.RemoteInfos(strutil.OrElse(remote, r.cfg.DefaultRemote)) 605 | if len(riMp) == 0 { 606 | return nil 607 | } 608 | 609 | if len(typ) > 0 { 610 | return riMp[typ[0]] 611 | } 612 | 613 | // get random type info 614 | for _, info := range riMp { 615 | return info 616 | } 617 | return nil // should never happen 618 | } 619 | 620 | // AllRemoteInfos get 621 | func (r *Repo) AllRemoteInfos() map[string]RemoteInfos { 622 | return r.loadRemoteInfos().remoteInfosMp 623 | } 624 | 625 | // AllRemoteInfos get 626 | func (r *Repo) loadRemoteInfos() *Repo { 627 | // has loaded 628 | if len(r.remoteNames) > 0 { 629 | return r 630 | } 631 | 632 | str, err := r.gw.Remote("-v").Output() 633 | if err != nil { 634 | r.setErr(err) 635 | return r 636 | } 637 | 638 | // origin https://github.com/gookit/gitw.git (fetch) 639 | // origin https://github.com/gookit/gitw.git (push) 640 | rmp := make(map[string]RemoteInfos, 2) 641 | str = strings.ReplaceAll(strings.TrimSpace(str), "\t", " ") 642 | 643 | names := make([]string, 0, 2) 644 | lines := strings.Split(str, "\n") 645 | 646 | for _, line := range lines { 647 | // origin https://github.com/gookit/gitw (push) 648 | ss := strutil.SplitN(line, " ", 3) 649 | if len(ss) < 3 { 650 | r.setErr(errorx.Rawf("invalid remote line: %s", line)) 651 | continue 652 | } 653 | 654 | name, url, typ := ss[0], ss[1], ss[2] 655 | typ = strings.Trim(typ, "()") 656 | 657 | // create instance 658 | ri, err := NewRemoteInfo(name, url, typ) 659 | if err != nil { 660 | r.setErr(err) 661 | continue 662 | } 663 | 664 | rs, ok := rmp[name] 665 | if !ok { 666 | rs = make(RemoteInfos, 2) 667 | } 668 | 669 | // add 670 | rs[typ] = ri 671 | rmp[name] = rs 672 | if !arrutil.StringsHas(names, name) { 673 | names = append(names, name) 674 | } 675 | } 676 | 677 | if len(names) > 0 { 678 | r.remoteNames = names 679 | r.remoteInfosMp = rmp 680 | } 681 | return r 682 | } 683 | 684 | // reset last error 685 | // func (r *Repo) resetErr() { 686 | // r.err = nil 687 | // } 688 | 689 | // ReadConfig contents from REPO/.git/config 690 | func (r *Repo) ReadConfig() []byte { 691 | return fsutil.GetContents(fsutil.JoinPaths(r.dir, GitDir, ConfFile)) 692 | } 693 | 694 | // ReadHEAD contents from REPO/.git/HEAD 695 | func (r *Repo) ReadHEAD() []byte { 696 | return fsutil.GetContents(fsutil.JoinPaths(r.dir, GitDir, HeadFile)) 697 | } 698 | 699 | // ------------------------------------------------- 700 | // helper methods 701 | // ------------------------------------------------- 702 | 703 | // IsValid check the dir is git repo 704 | func (r *Repo) IsValid() bool { return r.IsGitRepo() } 705 | 706 | // IsGitRepo check the dir is git repo 707 | func (r *Repo) IsGitRepo() bool { return r.gw.IsGitRepo() } 708 | 709 | // reset last error 710 | func (r *Repo) setErr(err error) { 711 | if err != nil { 712 | r.err = err 713 | } 714 | } 715 | 716 | // Err get last error 717 | func (r *Repo) Err() error { 718 | return r.err 719 | } 720 | 721 | // Dir get repo dir 722 | func (r *Repo) Dir() string { 723 | return r.dir 724 | } 725 | 726 | // Git get git wrapper 727 | func (r *Repo) Git() *GitWrap { 728 | return r.gw 729 | } 730 | 731 | // Cmd new git command wrapper 732 | func (r *Repo) Cmd(name string, args ...string) *GitWrap { 733 | return r.gw.Cmd(name, args...) 734 | } 735 | 736 | // QuickRun git command 737 | func (r *Repo) QuickRun(cmd string, args ...string) error { 738 | return r.gw.Cmd(cmd, args...).Run() 739 | } 740 | -------------------------------------------------------------------------------- /repo_test.go: -------------------------------------------------------------------------------- 1 | package gitw_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gookit/gitw" 7 | "github.com/gookit/goutil/dump" 8 | "github.com/gookit/goutil/sysutil" 9 | "github.com/gookit/goutil/testutil/assert" 10 | "github.com/gookit/slog" 11 | ) 12 | 13 | var repo = gitw.NewRepo("./").WithFn(func(r *gitw.Repo) { 14 | r.Git().BeforeExec = gitw.PrintCmdline 15 | }) 16 | 17 | func TestMain(m *testing.M) { 18 | slog.Println("workdir", sysutil.Workdir()) 19 | m.Run() 20 | } 21 | 22 | func TestRepo_StatusInfo(t *testing.T) { 23 | si := repo.StatusInfo() 24 | dump.P(si) 25 | } 26 | 27 | func TestRepo_RemoteInfos(t *testing.T) { 28 | rs := repo.AllRemoteInfos() 29 | dump.P(rs) 30 | 31 | assert.NoErr(t, repo.Err()) 32 | assert.NotEmpty(t, rs) 33 | 34 | assert.True(t, repo.HasRemote(gitw.DefaultRemoteName)) 35 | assert.NotEmpty(t, repo.RemoteNames()) 36 | } 37 | 38 | func TestRepo_DefaultRemoteInfo(t *testing.T) { 39 | rt := repo.DefaultRemoteInfo() 40 | dump.P(rt) 41 | 42 | assert.NotEmpty(t, rt) 43 | assert.True(t, rt.Valid()) 44 | assert.False(t, rt.Invalid()) 45 | assert.Eq(t, gitw.DefaultRemoteName, rt.Name) 46 | assert.Eq(t, "git@github.com:gookit/gitw.git", rt.GitURL()) 47 | assert.Eq(t, "http://github.com/gookit/gitw", rt.URLOfHTTP()) 48 | assert.Eq(t, "https://github.com/gookit/gitw", rt.URLOfHTTPS()) 49 | 50 | rt = repo.RandomRemoteInfo(gitw.RemoteTypePush) 51 | assert.NotEmpty(t, rt) 52 | } 53 | 54 | func TestRepo_AutoMatchTag(t *testing.T) { 55 | assert.Eq(t, "HEAD", repo.AutoMatchTag("head")) 56 | assert.Eq(t, "541fb9d", repo.AutoMatchTag("541fb9d")) 57 | } 58 | 59 | func TestRepo_BranchInfos(t *testing.T) { 60 | bs := repo.BranchInfos() 61 | assert.NotEmpty(t, bs) 62 | dump.P(bs.BrLines()) 63 | 64 | assert.NotEmpty(t, repo.SearchBranches("main", gitw.BrSearchAll)) 65 | 66 | cur := repo.CurBranchInfo() 67 | if cur != nil { 68 | assert.NotEmpty(t, cur) 69 | assert.NotEmpty(t, cur.Name) 70 | } 71 | 72 | mbr := repo.BranchInfo("main") 73 | if mbr != nil { 74 | assert.Eq(t, "main", mbr.Name) 75 | assert.Eq(t, "main", mbr.Short) 76 | } 77 | } 78 | 79 | func TestRepo_Info(t *testing.T) { 80 | info := repo.Info() 81 | dump.P(info) 82 | 83 | assert.Nil(t, repo.Err()) 84 | assert.NotNil(t, info) 85 | assert.Eq(t, "gitw", info.Name) 86 | } 87 | 88 | func TestRepo_AutoMatchTagByTagType(t *testing.T) { 89 | assert.Eq(t, "HEAD", repo.AutoMatchTagByType("head", 0)) 90 | assert.Eq(t, "541fb9d", repo.AutoMatchTagByType("541fb9d", 0)) 91 | } 92 | 93 | func TestRepo_TagsSortedByCreatorDate(t *testing.T) { 94 | tags := repo.TagsSortedByCreatorDate() 95 | dump.P(tags) 96 | assert.NotEmpty(t, tags) 97 | } 98 | 99 | func TestRepo_TagByDescribe(t *testing.T) { 100 | tags := repo.TagByDescribe("") 101 | dump.P(tags) 102 | assert.NotEmpty(t, tags) 103 | } 104 | -------------------------------------------------------------------------------- /testdata/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gookit/gitw/ba0255b69488a2d364d6145170c3afdfe1f55e98/testdata/.keep -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package gitw 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "os/exec" 8 | "regexp" 9 | "strings" 10 | 11 | "github.com/gookit/color" 12 | "github.com/gookit/goutil" 13 | "github.com/gookit/goutil/cliutil" 14 | "github.com/gookit/goutil/fsutil" 15 | "github.com/gookit/goutil/sysutil" 16 | "github.com/gookit/goutil/sysutil/cmdr" 17 | "github.com/gookit/slog" 18 | ) 19 | 20 | // MustString must return string, will panic on error 21 | func MustString(s string, err error) string { 22 | goutil.PanicIfErr(err) 23 | return s 24 | } 25 | 26 | // MustStrings must return strings, will panic on error 27 | func MustStrings(ss []string, err error) []string { 28 | goutil.PanicIfErr(err) 29 | return ss 30 | } 31 | 32 | // PrintCmdline on exec 33 | func PrintCmdline(gw *GitWrap) { 34 | color.Comment.Println(">", gw.String()) 35 | } 36 | 37 | // IsGitDir check 38 | func IsGitDir(dir string) bool { 39 | return New("--git-dir="+dir, "rev-parse", "--git-dir").Success() 40 | } 41 | 42 | // HasDotGitDir in the path 43 | func HasDotGitDir(path string) bool { 44 | return fsutil.IsDir(path + "/" + GitDir) 45 | } 46 | 47 | var editorCmd string 48 | 49 | // Editor returns program name of the editor. 50 | // from https://github.com/alibaba/git-repo-go/blob/master/editor/editor.go 51 | func Editor() string { 52 | if editorCmd != "" { 53 | return editorCmd 54 | } 55 | 56 | var env, str string 57 | if env = os.Getenv("GIT_EDITOR"); env != "" { 58 | str = env 59 | } else if env = Var("GIT_EDITOR"); env != "" { // git var GIT_EDITOR 60 | str = env 61 | } else if env = Config("core.editor"); env != "" { // git config --get core.editer OR git config core.editer 62 | str = env 63 | } else if env = os.Getenv("VISUAL"); env != "" { 64 | str = env 65 | } else if env = os.Getenv("EDITOR"); env != "" { 66 | str = env 67 | } else if os.Getenv("TERM") == "dumb" { 68 | slog.Fatal( 69 | "No editor specified in GIT_EDITOR, core.editor, VISUAL or EDITOR.\n" + 70 | "Tried to fall back to vi but terminal is dumb. Please configure at\n" + 71 | "least one of these before using this command.") 72 | } else { 73 | for _, c := range []string{"vim", "vi", "emacs", "nano"} { 74 | if path, err := exec.LookPath(c); err == nil { 75 | str = path 76 | break 77 | } 78 | } 79 | } 80 | 81 | // remove space and ':' 82 | editorCmd = strings.Trim(str, ": ") 83 | return editorCmd 84 | } 85 | 86 | // EditText starts an editor to edit data, and returns the edited data. 87 | func EditText(data string) string { 88 | var ( 89 | err error 90 | editor string 91 | ) 92 | 93 | editor = Editor() 94 | if !sysutil.IsTerminal(os.Stdout.Fd()) { 95 | slog.Println("no editor, input data unchanged") 96 | fmt.Println(data) 97 | return data 98 | } 99 | 100 | tmpFile, err := ioutil.TempFile("", "go-git-edit-file-*") 101 | if err != nil { 102 | slog.Fatal(err) 103 | } 104 | 105 | //goland:noinspection GoUnhandledErrorResult 106 | defer os.Remove(tmpFile.Name()) 107 | 108 | _, err = tmpFile.WriteString(data) 109 | if err != nil { 110 | slog.Fatal(err) 111 | } 112 | 113 | err = tmpFile.Close() 114 | if err != nil { 115 | slog.Fatal(err) 116 | } 117 | 118 | cmdArgs := editorCommands(editor, tmpFile.Name()) 119 | cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) 120 | cmd.Stdin = os.Stdin 121 | cmd.Stdout = os.Stdout 122 | cmd.Stderr = os.Stderr 123 | err = cmd.Run() 124 | if err != nil { 125 | slog.Errorf("fail to run '%s' to edit script: %s", strings.Join(cmdArgs, " "), err) 126 | } 127 | 128 | f, err := os.Open(tmpFile.Name()) 129 | if err != nil { 130 | slog.Fatal(err) 131 | } 132 | 133 | buf, err := ioutil.ReadAll(f) 134 | if err != nil { 135 | slog.Fatal(err) 136 | } 137 | return string(buf) 138 | } 139 | 140 | func editorCommands(editor string, args ...string) []string { 141 | var cmdArgs []string 142 | 143 | if sysutil.IsWindows() { 144 | // Split on spaces, respecting quoted strings 145 | if len(editor) > 0 && (editor[0] == '"' || editor[0] == '\'') { 146 | cmdArgs = cliutil.ParseLine(editor) 147 | 148 | // if err != nil { 149 | // log.Errorf("fail to parse editor '%s': %s", editor, err) 150 | // cmdArgs = append(cmdArgs, editor) 151 | // } 152 | } else { 153 | for i, c := range editor { 154 | if c == ' ' || c == '\t' { 155 | if fsutil.PathExists(editor[:i]) { 156 | cmdArgs = append(cmdArgs, editor[:i]) 157 | inArgs := cliutil.ParseLine(editor[i+1:]) 158 | cmdArgs = append(cmdArgs, inArgs...) 159 | 160 | // inArgs, err := shellwords.Parse(editor[i+1:]) 161 | // if err != nil { 162 | // log.Errorf("fail to parse args'%s': %s", editor[i+1:], err) 163 | // cmdArgs = append(cmdArgs, editor[i+1:]) 164 | // } else { 165 | // cmdArgs = append(cmdArgs, inArgs...) 166 | // } 167 | break 168 | } 169 | } 170 | } 171 | if len(cmdArgs) == 0 { 172 | cmdArgs = append(cmdArgs, editor) 173 | } 174 | } 175 | } else if regexp.MustCompile(`^.*[$ \t'].*$`).MatchString(editor) { 176 | // See: https://gerrit-review.googlesource.com/c/git-repo/+/16156 177 | cmdArgs = append(cmdArgs, "sh", "-c", editor+` "$@"`, "sh") 178 | } else { 179 | cmdArgs = append(cmdArgs, editor) 180 | } 181 | 182 | cmdArgs = append(cmdArgs, args...) 183 | return cmdArgs 184 | } 185 | 186 | // OutputLines split output to lines 187 | // 188 | // Deprecated: please use cmdr.OutputLines 189 | func OutputLines(output string) []string { return cmdr.OutputLines(output) } 190 | 191 | // FirstLine from command output. 192 | // 193 | // Deprecated: please use cmdr.FirstLine 194 | func FirstLine(output string) string { return cmdr.FirstLine(output) } 195 | 196 | func isDebugFromEnv() bool { 197 | return os.Getenv("GIT_CMD_VERBOSE") != "" 198 | } 199 | --------------------------------------------------------------------------------