├── .github ├── dependabot.yml ├── pull_request_template.md └── workflows │ └── go.yml ├── .gitignore ├── .golangci.yml ├── CODEOWNERS ├── LICENSE ├── Makefile ├── README.md ├── blame.go ├── blob.go ├── blob_test.go ├── codecov.yml ├── command.go ├── command_test.go ├── commit.go ├── commit_archive.go ├── commit_archive_test.go ├── commit_submodule.go ├── commit_submodule_test.go ├── commit_test.go ├── diff.go ├── diff_test.go ├── error.go ├── git.go ├── git_test.go ├── go.mod ├── go.sum ├── hook.go ├── hook_test.go ├── object.go ├── repo.go ├── repo_blame.go ├── repo_blame_test.go ├── repo_blob.go ├── repo_blob_test.go ├── repo_commit.go ├── repo_commit_test.go ├── repo_diff.go ├── repo_diff_test.go ├── repo_grep.go ├── repo_grep_test.go ├── repo_hook.go ├── repo_hook_test.go ├── repo_pull.go ├── repo_pull_test.go ├── repo_reference.go ├── repo_reference_test.go ├── repo_remote.go ├── repo_remote_test.go ├── repo_tag.go ├── repo_tag_test.go ├── repo_test.go ├── repo_tree.go ├── repo_tree_test.go ├── server.go ├── server_test.go ├── sha1.go ├── sha1_test.go ├── signature.go ├── signature_test.go ├── tag.go ├── tag_test.go ├── tree.go ├── tree_blob.go ├── tree_blob_test.go ├── tree_entry.go ├── tree_entry_test.go └── utils.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Docs: https://git.io/JCUAY 2 | version: 2 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | reviewers: 9 | - "gogs/core" 10 | commit-message: 11 | prefix: "mod:" 12 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Describe the pull request 2 | 3 | A clear and concise description of what the pull request is about, i.e. what problem should be fixed? 4 | 5 | Link to the issue: 6 | 7 | ### Checklist 8 | 9 | - [ ] I agree to follow the [Code of Conduct](https://go.dev/conduct) by submitting this pull request. 10 | - [ ] I have read and acknowledge the [Contributing guide](https://github.com/gogs/gogs/blob/main/.github/CONTRIBUTING.md). 11 | - [ ] I have added test cases to cover the new code. 12 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: 3 | push: 4 | branches: [ master ] 5 | paths: 6 | - '**.go' 7 | - 'go.mod' 8 | - '.golangci.yml' 9 | - '.github/workflows/go.yml' 10 | pull_request: 11 | paths: 12 | - '**.go' 13 | - 'go.mod' 14 | - '.golangci.yml' 15 | - '.github/workflows/go.yml' 16 | env: 17 | GOPROXY: "https://proxy.golang.org" 18 | 19 | jobs: 20 | lint: 21 | name: Lint 22 | concurrency: 23 | group: ${{ github.workflow }}-lint-${{ github.ref }} 24 | cancel-in-progress: true 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout code 28 | uses: actions/checkout@v4 29 | - name: Install Go 30 | uses: actions/setup-go@v5 31 | with: 32 | go-version: 1.24.x 33 | - name: Check Go module tidiness 34 | shell: bash 35 | run: | 36 | go mod tidy 37 | STATUS=$(git status --porcelain) 38 | if [ ! -z "$STATUS" ]; then 39 | echo "Unstaged files:" 40 | echo $STATUS 41 | echo "Run 'go mod tidy' and commit them" 42 | exit 1 43 | fi 44 | - name: Run golangci-lint 45 | uses: golangci/golangci-lint-action@v3 46 | with: 47 | version: latest 48 | args: --timeout=30m 49 | 50 | test: 51 | name: Test 52 | strategy: 53 | matrix: 54 | go-version: [ 1.24.x ] 55 | platform: [ ubuntu-latest, macos-latest, windows-latest ] 56 | runs-on: ${{ matrix.platform }} 57 | steps: 58 | - name: Checkout code 59 | uses: actions/checkout@v4 60 | - name: Install Go 61 | uses: actions/setup-go@v5 62 | with: 63 | go-version: ${{ matrix.go-version }} 64 | - name: Run tests with coverage 65 | run: go test -v -race -coverprofile=coverage -covermode=atomic ./... 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.sublime-project 3 | *.sublime-workspace 4 | /testdata 5 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | staticcheck: 3 | checks: [ 4 | "all", 5 | "-SA1019" # There are valid use cases of strings.Title 6 | ] 7 | nakedret: 8 | max-func-lines: 0 # Disallow any unnamed return statement 9 | 10 | linters: 11 | enable: 12 | - unused 13 | - errcheck 14 | - gosimple 15 | - govet 16 | - ineffassign 17 | - staticcheck 18 | - typecheck 19 | - nakedret 20 | - gofmt 21 | - rowserrcheck 22 | - unconvert 23 | - goimports 24 | - unparam 25 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Default 2 | * @gogs/core 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 All Gogs Contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: vet test bench coverage 2 | 3 | vet: 4 | go vet 5 | 6 | test: 7 | go test -v -cover -race 8 | 9 | bench: 10 | go test -v -cover -test.bench=. -test.benchmem 11 | 12 | coverage: 13 | go test -coverprofile=c.out && go tool cover -html=c.out && rm c.out 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Git Module 2 | 3 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/gogs/git-module/Go?logo=github&style=for-the-badge)](https://github.com/gogs/git-module/actions?query=workflow%3AGo) 4 | [![codecov](https://img.shields.io/codecov/c/github/gogs/git-module/master?logo=codecov&style=for-the-badge)](https://codecov.io/gh/gogs/git-module) 5 | [![GoDoc](https://img.shields.io/badge/GoDoc-Reference-blue?style=for-the-badge&logo=go)](https://pkg.go.dev/github.com/gogs/git-module?tab=doc) 6 | [![Sourcegraph](https://img.shields.io/badge/view%20on-Sourcegraph-brightgreen.svg?style=for-the-badge&logo=sourcegraph)](https://sourcegraph.com/github.com/gogs/git-module) 7 | 8 | Package git-module is a Go module for Git access through shell commands. 9 | 10 | ## Requirements 11 | 12 | - Git version must be no less than **1.8.3**. 13 | - For Windows users, try to use the latest version of both. 14 | 15 | ## License 16 | 17 | This project is under the MIT License. See the [LICENSE](LICENSE) file for the full license text. 18 | -------------------------------------------------------------------------------- /blame.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | // Blame contains information of a Git file blame. 8 | type Blame struct { 9 | lines []*Commit 10 | } 11 | 12 | // Line returns the commit by given line number (1-based). It returns nil when 13 | // no such line. 14 | func (b *Blame) Line(i int) *Commit { 15 | if i <= 0 || len(b.lines) < i { 16 | return nil 17 | } 18 | return b.lines[i-1] 19 | } 20 | -------------------------------------------------------------------------------- /blob.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "bytes" 9 | "io" 10 | ) 11 | 12 | // Blob is a blob object. 13 | type Blob struct { 14 | *TreeEntry 15 | } 16 | 17 | // Bytes reads and returns the content of the blob all at once in bytes. This 18 | // can be very slow and memory consuming for huge content. 19 | func (b *Blob) Bytes() ([]byte, error) { 20 | stdout := new(bytes.Buffer) 21 | stderr := new(bytes.Buffer) 22 | 23 | // Preallocate memory to save ~50% memory usage on big files. 24 | stdout.Grow(int(b.Size())) 25 | 26 | if err := b.Pipeline(stdout, stderr); err != nil { 27 | return nil, concatenateError(err, stderr.String()) 28 | } 29 | return stdout.Bytes(), nil 30 | } 31 | 32 | // Pipeline reads the content of the blob and pipes stdout and stderr to 33 | // supplied io.Writer. 34 | func (b *Blob) Pipeline(stdout, stderr io.Writer) error { 35 | return NewCommand("show", b.id.String()).RunInDirPipeline(stdout, stderr, b.parent.repo.path) 36 | } 37 | -------------------------------------------------------------------------------- /blob_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "bytes" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestBlob(t *testing.T) { 15 | expOutput := `This is a sample project students can use during Matthew's Git class. 16 | 17 | Here is an addition by me 18 | 19 | We can have a bit of fun with this repo, knowing that we can always reset it to a known good state. We can apply labels, and branch, then add new code and merge it in to the master branch. 20 | 21 | As a quick reminder, this came from one of three locations in either SSH, Git, or HTTPS format: 22 | 23 | * git@github.com:matthewmccullough/hellogitworld.git 24 | * git://github.com/matthewmccullough/hellogitworld.git 25 | * https://matthewmccullough@github.com/matthewmccullough/hellogitworld.git 26 | 27 | We can, as an example effort, even modify this README and change it as if it were source code for the purposes of the class. 28 | 29 | This demo also includes an image with changes on a branch for examination of image diff on GitHub. 30 | ` 31 | 32 | blob := &Blob{ 33 | TreeEntry: &TreeEntry{ 34 | mode: EntryBlob, 35 | typ: ObjectBlob, 36 | id: MustIDFromString("adfd6da3c0a3fb038393144becbf37f14f780087"), // Blob ID of "README.txt" file 37 | parent: &Tree{ 38 | repo: testrepo, 39 | }, 40 | }, 41 | } 42 | 43 | t.Run("get data all at once", func(t *testing.T) { 44 | p, err := blob.Bytes() 45 | assert.Nil(t, err) 46 | assert.Equal(t, expOutput, string(p)) 47 | }) 48 | 49 | t.Run("get data with pipeline", func(t *testing.T) { 50 | stdout := new(bytes.Buffer) 51 | err := blob.Pipeline(stdout, nil) 52 | assert.Nil(t, err) 53 | assert.Equal(t, expOutput, stdout.String()) 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: "60...95" 3 | status: 4 | project: 5 | default: 6 | threshold: 1% 7 | informational: true 8 | patch: 9 | defualt: 10 | only_pulls: true 11 | informational: true 12 | 13 | comment: 14 | layout: 'diff' 15 | 16 | github_checks: false 17 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "fmt" 11 | "io" 12 | "os" 13 | "os/exec" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | // Command contains the name, arguments and environment variables of a command. 19 | type Command struct { 20 | name string 21 | args []string 22 | envs []string 23 | timeout time.Duration 24 | ctx context.Context 25 | } 26 | 27 | // CommandOptions contains options for running a command. 28 | // If timeout is zero, DefaultTimeout will be used. 29 | // If timeout is less than zero, no timeout will be set. 30 | // If context is nil, context.Background() will be used. 31 | type CommandOptions struct { 32 | Args []string 33 | Envs []string 34 | Timeout time.Duration 35 | Context context.Context 36 | } 37 | 38 | // String returns the string representation of the command. 39 | func (c *Command) String() string { 40 | if len(c.args) == 0 { 41 | return c.name 42 | } 43 | return fmt.Sprintf("%s %s", c.name, strings.Join(c.args, " ")) 44 | } 45 | 46 | // NewCommand creates and returns a new Command with given arguments for "git". 47 | func NewCommand(args ...string) *Command { 48 | return NewCommandWithContext(context.Background(), args...) 49 | } 50 | 51 | // NewCommandWithContext creates and returns a new Command with given arguments 52 | // and context for "git". 53 | func NewCommandWithContext(ctx context.Context, args ...string) *Command { 54 | return &Command{ 55 | name: "git", 56 | args: args, 57 | ctx: ctx, 58 | } 59 | } 60 | 61 | // AddArgs appends given arguments to the command. 62 | func (c *Command) AddArgs(args ...string) *Command { 63 | c.args = append(c.args, args...) 64 | return c 65 | } 66 | 67 | // AddEnvs appends given environment variables to the command. 68 | func (c *Command) AddEnvs(envs ...string) *Command { 69 | c.envs = append(c.envs, envs...) 70 | return c 71 | } 72 | 73 | // WithContext returns a new Command with the given context. 74 | func (c Command) WithContext(ctx context.Context) *Command { 75 | c.ctx = ctx 76 | return &c 77 | } 78 | 79 | // WithTimeout returns a new Command with given timeout. 80 | func (c Command) WithTimeout(timeout time.Duration) *Command { 81 | c.timeout = timeout 82 | return &c 83 | } 84 | 85 | // SetTimeout sets the timeout for the command. 86 | func (c *Command) SetTimeout(timeout time.Duration) { 87 | c.timeout = timeout 88 | } 89 | 90 | // AddOptions adds options to the command. 91 | // Note: only the last option will take effect if there are duplicated options. 92 | func (c *Command) AddOptions(opts ...CommandOptions) *Command { 93 | for _, opt := range opts { 94 | c.timeout = opt.Timeout 95 | c.ctx = opt.Context 96 | c.AddArgs(opt.Args...) 97 | c.AddEnvs(opt.Envs...) 98 | } 99 | return c 100 | } 101 | 102 | // AddCommitter appends given committer to the command. 103 | func (c *Command) AddCommitter(committer *Signature) *Command { 104 | c.AddEnvs("GIT_COMMITTER_NAME="+committer.Name, "GIT_COMMITTER_EMAIL="+committer.Email) 105 | return c 106 | } 107 | 108 | // DefaultTimeout is the default timeout duration for all commands. 109 | const DefaultTimeout = time.Minute 110 | 111 | // A limitDualWriter writes to W but limits the amount of data written to just N 112 | // bytes. On the other hand, it passes everything to w. 113 | type limitDualWriter struct { 114 | W io.Writer // underlying writer 115 | N int64 // max bytes remaining 116 | prompted bool 117 | 118 | w io.Writer 119 | } 120 | 121 | func (w *limitDualWriter) Write(p []byte) (int, error) { 122 | if w.N > 0 { 123 | limit := int64(len(p)) 124 | if limit > w.N { 125 | limit = w.N 126 | } 127 | n, _ := w.W.Write(p[:limit]) 128 | w.N -= int64(n) 129 | } 130 | 131 | if !w.prompted && w.N <= 0 { 132 | w.prompted = true 133 | _, _ = w.W.Write([]byte("... (more omitted)")) 134 | } 135 | 136 | return w.w.Write(p) 137 | } 138 | 139 | // RunInDirOptions contains options for running a command in a directory. 140 | type RunInDirOptions struct { 141 | // Stdin is the input to the command. 142 | Stdin io.Reader 143 | // Stdout is the outputs from the command. 144 | Stdout io.Writer 145 | // Stderr is the error output from the command. 146 | Stderr io.Writer 147 | // Timeout is the duration to wait before timing out. 148 | // 149 | // Deprecated: Use CommandOptions.Timeout or *Command.WithTimeout instead. 150 | Timeout time.Duration 151 | } 152 | 153 | // RunInDirWithOptions executes the command in given directory and options. It 154 | // pipes stdin from supplied io.Reader, and pipes stdout and stderr to supplied 155 | // io.Writer. DefaultTimeout will be used if the timeout duration is less than 156 | // time.Nanosecond (i.e. less than or equal to 0). It returns an ErrExecTimeout 157 | // if the execution was timed out. 158 | func (c *Command) RunInDirWithOptions(dir string, opts ...RunInDirOptions) (err error) { 159 | var opt RunInDirOptions 160 | if len(opts) > 0 { 161 | opt = opts[0] 162 | } 163 | 164 | timeout := c.timeout 165 | // TODO: remove this in newer version 166 | if opt.Timeout > 0 { 167 | timeout = opt.Timeout 168 | } 169 | 170 | if timeout == 0 { 171 | timeout = DefaultTimeout 172 | } 173 | 174 | buf := new(bytes.Buffer) 175 | w := opt.Stdout 176 | if logOutput != nil { 177 | buf.Grow(512) 178 | w = &limitDualWriter{ 179 | W: buf, 180 | N: int64(buf.Cap()), 181 | w: opt.Stdout, 182 | } 183 | } 184 | 185 | defer func() { 186 | if len(dir) == 0 { 187 | log("[timeout: %v] %s\n%s", timeout, c, buf.Bytes()) 188 | } else { 189 | log("[timeout: %v] %s: %s\n%s", timeout, dir, c, buf.Bytes()) 190 | } 191 | }() 192 | 193 | ctx := context.Background() 194 | if c.ctx != nil { 195 | ctx = c.ctx 196 | } 197 | 198 | if timeout > 0 { 199 | var cancel context.CancelFunc 200 | ctx, cancel = context.WithTimeout(ctx, timeout) 201 | defer func() { 202 | cancel() 203 | if err == context.DeadlineExceeded { 204 | err = ErrExecTimeout 205 | } 206 | }() 207 | } 208 | 209 | cmd := exec.CommandContext(ctx, c.name, c.args...) 210 | if len(c.envs) > 0 { 211 | cmd.Env = append(os.Environ(), c.envs...) 212 | } 213 | cmd.Dir = dir 214 | cmd.Stdin = opt.Stdin 215 | cmd.Stdout = w 216 | cmd.Stderr = opt.Stderr 217 | if err = cmd.Start(); err != nil { 218 | return err 219 | } 220 | 221 | result := make(chan error) 222 | go func() { 223 | result <- cmd.Wait() 224 | }() 225 | 226 | select { 227 | case <-ctx.Done(): 228 | <-result 229 | if cmd.Process != nil && cmd.ProcessState != nil && !cmd.ProcessState.Exited() { 230 | if err := cmd.Process.Kill(); err != nil { 231 | return fmt.Errorf("kill process: %v", err) 232 | } 233 | } 234 | 235 | return ErrExecTimeout 236 | case err = <-result: 237 | return err 238 | } 239 | 240 | } 241 | 242 | // RunInDirPipeline executes the command in given directory and default timeout 243 | // duration. It pipes stdout and stderr to supplied io.Writer. 244 | func (c *Command) RunInDirPipeline(stdout, stderr io.Writer, dir string) error { 245 | return c.RunInDirWithOptions(dir, RunInDirOptions{ 246 | Stdin: nil, 247 | Stdout: stdout, 248 | Stderr: stderr, 249 | }) 250 | } 251 | 252 | // RunInDirPipelineWithTimeout executes the command in given directory and 253 | // timeout duration. It pipes stdout and stderr to supplied io.Writer. 254 | // DefaultTimeout will be used if the timeout duration is less than 255 | // time.Nanosecond (i.e. less than or equal to 0). It returns an ErrExecTimeout 256 | // if the execution was timed out. 257 | // 258 | // Deprecated: Use RunInDirPipeline and CommandOptions instead. 259 | // TODO: remove this in the next major version 260 | func (c *Command) RunInDirPipelineWithTimeout(timeout time.Duration, stdout, stderr io.Writer, dir string) (err error) { 261 | if timeout != 0 { 262 | c = c.WithTimeout(timeout) 263 | } 264 | return c.RunInDirPipeline(stdout, stderr, dir) 265 | } 266 | 267 | // RunInDirWithTimeout executes the command in given directory and timeout 268 | // duration. It returns stdout in []byte and error (combined with stderr). 269 | // 270 | // Deprecated: Use RunInDir and CommandOptions instead. 271 | // TODO: remove this in the next major version 272 | func (c *Command) RunInDirWithTimeout(timeout time.Duration, dir string) ([]byte, error) { 273 | if timeout != 0 { 274 | c = c.WithTimeout(timeout) 275 | } 276 | return c.RunInDir(dir) 277 | } 278 | 279 | // RunInDir executes the command in given directory and default timeout 280 | // duration. It returns stdout and error (combined with stderr). 281 | func (c *Command) RunInDir(dir string) ([]byte, error) { 282 | stdout := new(bytes.Buffer) 283 | stderr := new(bytes.Buffer) 284 | if err := c.RunInDirPipeline(stdout, stderr, dir); err != nil { 285 | return nil, concatenateError(err, stderr.String()) 286 | } 287 | return stdout.Bytes(), nil 288 | } 289 | 290 | // RunWithTimeout executes the command in working directory and given timeout 291 | // duration. It returns stdout in string and error (combined with stderr). 292 | // 293 | // Deprecated: Use RunInDir and CommandOptions instead. 294 | // TODO: remove this in the next major version 295 | func (c *Command) RunWithTimeout(timeout time.Duration) ([]byte, error) { 296 | if timeout != 0 { 297 | c = c.WithTimeout(timeout) 298 | } 299 | return c.Run() 300 | } 301 | 302 | // Run executes the command in working directory and default timeout duration. 303 | // It returns stdout in string and error (combined with stderr). 304 | func (c *Command) Run() ([]byte, error) { 305 | stdout, err := c.RunInDir("") 306 | if err != nil { 307 | return nil, err 308 | } 309 | return stdout, nil 310 | } 311 | -------------------------------------------------------------------------------- /command_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestCommand_String(t *testing.T) { 15 | tests := []struct { 16 | name string 17 | args []string 18 | expStr string 19 | }{ 20 | { 21 | name: "no args", 22 | args: nil, 23 | expStr: "git", 24 | }, 25 | { 26 | name: "has one arg", 27 | args: []string{"version"}, 28 | expStr: "git version", 29 | }, 30 | { 31 | name: "has more args", 32 | args: []string{"config", "--global", "http.proxy", "http://localhost:8080"}, 33 | expStr: "git config --global http.proxy http://localhost:8080", 34 | }, 35 | } 36 | for _, test := range tests { 37 | t.Run(test.name, func(t *testing.T) { 38 | cmd := NewCommand(test.args...) 39 | assert.Equal(t, test.expStr, cmd.String()) 40 | }) 41 | } 42 | } 43 | 44 | func TestCommand_AddArgs(t *testing.T) { 45 | cmd := NewCommand() 46 | assert.Equal(t, []string(nil), cmd.args) 47 | 48 | cmd.AddArgs("push") 49 | cmd.AddArgs("origin", "master") 50 | assert.Equal(t, []string{"push", "origin", "master"}, cmd.args) 51 | } 52 | 53 | func TestCommand_AddEnvs(t *testing.T) { 54 | cmd := NewCommand() 55 | assert.Equal(t, []string(nil), cmd.envs) 56 | 57 | cmd.AddEnvs("GIT_DIR=/tmp") 58 | cmd.AddEnvs("HOME=/Users/unknwon", "GIT_EDITOR=code") 59 | assert.Equal(t, []string{"GIT_DIR=/tmp", "HOME=/Users/unknwon", "GIT_EDITOR=code"}, cmd.envs) 60 | } 61 | 62 | func TestCommand_RunWithTimeout(t *testing.T) { 63 | _, err := NewCommand("version").WithTimeout(time.Nanosecond).Run() 64 | assert.Equal(t, ErrExecTimeout, err) 65 | } 66 | -------------------------------------------------------------------------------- /commit.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "bytes" 9 | "io" 10 | "io/ioutil" 11 | "net/http" 12 | "strings" 13 | "sync" 14 | ) 15 | 16 | // Commit contains information of a Git commit. 17 | type Commit struct { 18 | // The SHA-1 hash of the commit. 19 | ID *SHA1 20 | // The author of the commit. 21 | Author *Signature 22 | // The committer of the commit. 23 | Committer *Signature 24 | // The full commit message. 25 | Message string 26 | 27 | parents []*SHA1 28 | *Tree 29 | 30 | submodules Submodules 31 | submodulesOnce sync.Once 32 | submodulesErr error 33 | } 34 | 35 | // Summary returns first line of commit message. 36 | func (c *Commit) Summary() string { 37 | return strings.Split(c.Message, "\n")[0] 38 | } 39 | 40 | // ParentsCount returns number of parents of the commit. It returns 0 if this is 41 | // the root commit, otherwise returns 1, 2, etc. 42 | func (c *Commit) ParentsCount() int { 43 | return len(c.parents) 44 | } 45 | 46 | // ParentID returns the SHA-1 hash of the n-th parent (0-based) of this commit. 47 | // It returns an ErrParentNotExist if no such parent exists. 48 | func (c *Commit) ParentID(n int) (*SHA1, error) { 49 | if n >= len(c.parents) { 50 | return nil, ErrParentNotExist 51 | } 52 | return c.parents[n], nil 53 | } 54 | 55 | // Parent returns the n-th parent commit (0-based) of this commit. It returns 56 | // ErrRevisionNotExist if no such parent exists. 57 | func (c *Commit) Parent(n int, opts ...CatFileCommitOptions) (*Commit, error) { 58 | id, err := c.ParentID(n) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | return c.repo.CatFileCommit(id.String(), opts...) 64 | } 65 | 66 | // CommitByPath returns the commit of the path in the state of this commit. 67 | func (c *Commit) CommitByPath(opts ...CommitByRevisionOptions) (*Commit, error) { 68 | return c.repo.CommitByRevision(c.ID.String(), opts...) 69 | } 70 | 71 | // CommitsByPage returns a paginated list of commits in the state of this 72 | // commit. The returned list is in reverse chronological order. 73 | func (c *Commit) CommitsByPage(page, size int, opts ...CommitsByPageOptions) ([]*Commit, error) { 74 | return c.repo.CommitsByPage(c.ID.String(), page, size, opts...) 75 | } 76 | 77 | // SearchCommits searches commit message with given pattern. The returned list 78 | // is in reverse chronological order. 79 | func (c *Commit) SearchCommits(pattern string, opts ...SearchCommitsOptions) ([]*Commit, error) { 80 | return c.repo.SearchCommits(c.ID.String(), pattern, opts...) 81 | } 82 | 83 | // ShowNameStatus returns name status of the commit. 84 | func (c *Commit) ShowNameStatus(opts ...ShowNameStatusOptions) (*NameStatus, error) { 85 | return c.repo.ShowNameStatus(c.ID.String(), opts...) 86 | } 87 | 88 | // CommitsCount returns number of total commits up to this commit. 89 | func (c *Commit) CommitsCount(opts ...RevListCountOptions) (int64, error) { 90 | return c.repo.RevListCount([]string{c.ID.String()}, opts...) 91 | } 92 | 93 | // FilesChangedAfter returns a list of files changed after given commit ID. 94 | func (c *Commit) FilesChangedAfter(after string, opts ...DiffNameOnlyOptions) ([]string, error) { 95 | return c.repo.DiffNameOnly(after, c.ID.String(), opts...) 96 | } 97 | 98 | // CommitsAfter returns a list of commits after given commit ID up to this 99 | // commit. The returned list is in reverse chronological order. 100 | func (c *Commit) CommitsAfter(after string, opts ...RevListOptions) ([]*Commit, error) { 101 | return c.repo.RevList([]string{after + "..." + c.ID.String()}, opts...) 102 | } 103 | 104 | // Ancestors returns a list of ancestors of this commit in reverse chronological 105 | // order. 106 | func (c *Commit) Ancestors(opts ...LogOptions) ([]*Commit, error) { 107 | if c.ParentsCount() == 0 { 108 | return []*Commit{}, nil 109 | } 110 | 111 | var opt LogOptions 112 | if len(opts) > 0 { 113 | opt = opts[0] 114 | } 115 | 116 | opt.Skip++ 117 | 118 | return c.repo.Log(c.ID.String(), opt) 119 | } 120 | 121 | type limitWriter struct { 122 | W io.Writer 123 | N int64 124 | } 125 | 126 | func (w *limitWriter) Write(p []byte) (int, error) { 127 | if w.N <= 0 { 128 | return len(p), nil 129 | } 130 | 131 | limit := int64(len(p)) 132 | if limit > w.N { 133 | limit = w.N 134 | } 135 | n, err := w.W.Write(p[:limit]) 136 | w.N -= int64(n) 137 | 138 | // Prevent "short write" error 139 | return len(p), err 140 | } 141 | 142 | func (c *Commit) isImageFile(blob *Blob, err error) (bool, error) { 143 | if err != nil { 144 | if err == ErrNotBlob { 145 | return false, nil 146 | } 147 | return false, err 148 | } 149 | 150 | buf := new(bytes.Buffer) 151 | buf.Grow(512) 152 | stdout := &limitWriter{ 153 | W: buf, 154 | N: int64(buf.Cap()), 155 | } 156 | 157 | err = blob.Pipeline(stdout, ioutil.Discard) 158 | if err != nil { 159 | return false, err 160 | } 161 | 162 | return strings.Contains(http.DetectContentType(buf.Bytes()), "image/"), nil 163 | } 164 | 165 | // IsImageFile returns true if the blob of the commit is an image by subpath. 166 | func (c *Commit) IsImageFile(subpath string) (bool, error) { 167 | return c.isImageFile(c.Blob(subpath)) 168 | } 169 | 170 | // IsImageFileByIndex returns true if the blob of the commit is an image by 171 | // index. 172 | func (c *Commit) IsImageFileByIndex(index string) (bool, error) { 173 | return c.isImageFile(c.BlobByIndex(index)) 174 | } 175 | -------------------------------------------------------------------------------- /commit_archive.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | // ArchiveFormat is the format of an archive. 13 | type ArchiveFormat string 14 | 15 | // A list of formats can be created by Git for an archive. 16 | const ( 17 | ArchiveZip ArchiveFormat = "zip" 18 | ArchiveTarGz ArchiveFormat = "tar.gz" 19 | ) 20 | 21 | // CreateArchive creates given format of archive to the destination. 22 | func (c *Commit) CreateArchive(format ArchiveFormat, dst string) error { 23 | prefix := filepath.Base(strings.TrimSuffix(c.repo.path, ".git")) + "/" 24 | _, err := NewCommand("archive", 25 | "--prefix="+prefix, 26 | "--format="+string(format), 27 | "-o", dst, 28 | c.ID.String(), 29 | ).RunInDir(c.repo.path) 30 | return err 31 | } 32 | -------------------------------------------------------------------------------- /commit_archive_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "os" 9 | "path/filepath" 10 | "strconv" 11 | "testing" 12 | "time" 13 | 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func tempPath() string { 18 | return filepath.Join(os.TempDir(), strconv.Itoa(int(time.Now().UnixNano()))) 19 | } 20 | 21 | func TestCommit_CreateArchive(t *testing.T) { 22 | for _, format := range []ArchiveFormat{ 23 | ArchiveZip, 24 | ArchiveTarGz, 25 | } { 26 | t.Run(string(format), func(t *testing.T) { 27 | c, err := testrepo.CatFileCommit("755fd577edcfd9209d0ac072eed3b022cbe4d39b") 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | 32 | dst := tempPath() 33 | defer func() { 34 | _ = os.Remove(dst) 35 | }() 36 | 37 | assert.Nil(t, c.CreateArchive(format, dst)) 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /commit_submodule.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "bufio" 9 | "bytes" 10 | "strings" 11 | ) 12 | 13 | // Submodule contains information of a Git submodule. 14 | type Submodule struct { 15 | // The name of the submodule. 16 | Name string 17 | // The URL of the submodule. 18 | URL string 19 | // The commit ID of the subproject. 20 | Commit string 21 | } 22 | 23 | // Submodules contains information of submodules. 24 | type Submodules = *objectCache 25 | 26 | // Submodules returns submodules found in this commit. 27 | func (c *Commit) Submodules() (Submodules, error) { 28 | c.submodulesOnce.Do(func() { 29 | var e *TreeEntry 30 | e, c.submodulesErr = c.TreeEntry(".gitmodules") 31 | if c.submodulesErr != nil { 32 | return 33 | } 34 | 35 | var p []byte 36 | p, c.submodulesErr = e.Blob().Bytes() 37 | if c.submodulesErr != nil { 38 | return 39 | } 40 | 41 | scanner := bufio.NewScanner(bytes.NewReader(p)) 42 | c.submodules = newObjectCache() 43 | var inSection bool 44 | var path string 45 | var url string 46 | for scanner.Scan() { 47 | if strings.HasPrefix(scanner.Text(), "[submodule") { 48 | inSection = true 49 | path = "" 50 | url = "" 51 | continue 52 | } else if !inSection { 53 | continue 54 | } 55 | 56 | fields := strings.Split(scanner.Text(), "=") 57 | switch strings.TrimSpace(fields[0]) { 58 | case "path": 59 | path = strings.TrimSpace(fields[1]) 60 | case "url": 61 | url = strings.TrimSpace(fields[1]) 62 | } 63 | 64 | if len(path) > 0 && len(url) > 0 { 65 | mod := &Submodule{ 66 | Name: path, 67 | URL: url, 68 | } 69 | 70 | mod.Commit, c.submodulesErr = c.repo.RevParse(c.id.String() + ":" + mod.Name) 71 | if c.submodulesErr != nil { 72 | return 73 | } 74 | 75 | c.submodules.Set(path, mod) 76 | inSection = false 77 | } 78 | } 79 | }) 80 | 81 | return c.submodules, c.submodulesErr 82 | } 83 | 84 | // Submodule returns submodule by given name. It returns an ErrSubmoduleNotExist 85 | // if the path does not exist as a submodule. 86 | func (c *Commit) Submodule(path string) (*Submodule, error) { 87 | mods, err := c.Submodules() 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | m, has := mods.Get(path) 93 | if has { 94 | return m.(*Submodule), nil 95 | } 96 | return nil, ErrSubmoduleNotExist 97 | } 98 | -------------------------------------------------------------------------------- /commit_submodule_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestCommit_Submodule(t *testing.T) { 14 | c, err := testrepo.CatFileCommit("4e59b72440188e7c2578299fc28ea425fbe9aece") 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | 19 | mod, err := c.Submodule("gogs/docs-api") 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | assert.Equal(t, "gogs/docs-api", mod.Name) 24 | assert.Equal(t, "https://github.com/gogs/docs-api.git", mod.URL) 25 | assert.Equal(t, "6b08f76a5313fa3d26859515b30aa17a5faa2807", mod.Commit) 26 | 27 | _, err = c.Submodule("404") 28 | assert.Equal(t, ErrSubmoduleNotExist, err) 29 | } 30 | -------------------------------------------------------------------------------- /commit_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestCommit(t *testing.T) { 14 | c, err := testrepo.CatFileCommit("435ffceb7ba576c937e922766e37d4f7abdcc122") 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | t.Run("ID", func(t *testing.T) { 19 | assert.Equal(t, "435ffceb7ba576c937e922766e37d4f7abdcc122", c.ID.String()) 20 | }) 21 | 22 | t.Run("Summary", func(t *testing.T) { 23 | assert.Equal(t, "Merge pull request #35 from githubtraining/travis-yml-docker", c.Summary()) 24 | }) 25 | } 26 | 27 | func TestCommit_Parent(t *testing.T) { 28 | c, err := testrepo.CatFileCommit("435ffceb7ba576c937e922766e37d4f7abdcc122") 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | 33 | t.Run("ParentsCount", func(t *testing.T) { 34 | assert.Equal(t, 2, c.ParentsCount()) 35 | }) 36 | 37 | t.Run("Parent", func(t *testing.T) { 38 | t.Run("no such parent", func(t *testing.T) { 39 | _, err := c.Parent(c.ParentsCount() + 1) 40 | assert.Equal(t, ErrParentNotExist, err) 41 | }) 42 | 43 | tests := []struct { 44 | n int 45 | expParentID string 46 | }{ 47 | { 48 | n: 0, 49 | expParentID: "a13dba1e469944772490909daa58c53ac8fa4b0d", 50 | }, 51 | { 52 | n: 1, 53 | expParentID: "7c5ee6478d137417ae602140c615e33aed91887c", 54 | }, 55 | } 56 | for _, test := range tests { 57 | t.Run("", func(t *testing.T) { 58 | p, err := c.Parent(test.n) 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | assert.Equal(t, test.expParentID, p.ID.String()) 63 | }) 64 | } 65 | }) 66 | } 67 | 68 | func TestCommit_CommitByPath(t *testing.T) { 69 | tests := []struct { 70 | id string 71 | opt CommitByRevisionOptions 72 | expCommitID string 73 | }{ 74 | { 75 | id: "2a52e96389d02209b451ae1ddf45d645b42d744c", 76 | opt: CommitByRevisionOptions{ 77 | Path: "", // No path gets back to the commit itself 78 | }, 79 | expCommitID: "2a52e96389d02209b451ae1ddf45d645b42d744c", 80 | }, 81 | { 82 | id: "2a52e96389d02209b451ae1ddf45d645b42d744c", 83 | opt: CommitByRevisionOptions{ 84 | Path: "resources/labels.properties", 85 | }, 86 | expCommitID: "755fd577edcfd9209d0ac072eed3b022cbe4d39b", 87 | }, 88 | } 89 | for _, test := range tests { 90 | t.Run("", func(t *testing.T) { 91 | c, err := testrepo.CatFileCommit(test.id) 92 | if err != nil { 93 | t.Fatal(err) 94 | } 95 | 96 | cc, err := c.CommitByPath(test.opt) 97 | if err != nil { 98 | t.Fatal(err) 99 | } 100 | 101 | assert.Equal(t, test.expCommitID, cc.ID.String()) 102 | }) 103 | } 104 | } 105 | 106 | // commitsToIDs returns a list of IDs for given commits. 107 | func commitsToIDs(commits []*Commit) []string { 108 | ids := make([]string, len(commits)) 109 | for i := range commits { 110 | ids[i] = commits[i].ID.String() 111 | } 112 | return ids 113 | } 114 | 115 | func TestCommit_CommitsByPage(t *testing.T) { 116 | // There are at most 5 commits can be used for pagination before this commit. 117 | c, err := testrepo.CatFileCommit("f5ed01959cffa4758ca0a49bf4c34b138d7eab0a") 118 | if err != nil { 119 | t.Fatal(err) 120 | } 121 | 122 | tests := []struct { 123 | page int 124 | size int 125 | opt CommitsByPageOptions 126 | expCommitIDs []string 127 | }{ 128 | { 129 | page: 0, 130 | size: 2, 131 | expCommitIDs: []string{ 132 | "f5ed01959cffa4758ca0a49bf4c34b138d7eab0a", 133 | "9cdb160ee4118035bf73c744e3bf72a1ba16484a", 134 | }, 135 | }, 136 | { 137 | page: 1, 138 | size: 2, 139 | expCommitIDs: []string{ 140 | "f5ed01959cffa4758ca0a49bf4c34b138d7eab0a", 141 | "9cdb160ee4118035bf73c744e3bf72a1ba16484a", 142 | }, 143 | }, 144 | { 145 | page: 2, 146 | size: 2, 147 | expCommitIDs: []string{ 148 | "dc64fe4ab8618a5be491a9fca46f1585585ea44e", 149 | "32c273781bab599b955ce7c59d92c39bedf35db0", 150 | }, 151 | }, 152 | { 153 | page: 3, 154 | size: 2, 155 | expCommitIDs: []string{ 156 | "755fd577edcfd9209d0ac072eed3b022cbe4d39b", 157 | }, 158 | }, 159 | { 160 | page: 4, 161 | size: 2, 162 | expCommitIDs: []string{}, 163 | }, 164 | 165 | { 166 | page: 2, 167 | size: 2, 168 | opt: CommitsByPageOptions{ 169 | Path: "src", 170 | }, 171 | expCommitIDs: []string{ 172 | "755fd577edcfd9209d0ac072eed3b022cbe4d39b", 173 | }, 174 | }, 175 | } 176 | for _, test := range tests { 177 | t.Run("", func(t *testing.T) { 178 | commits, err := c.CommitsByPage(test.page, test.size, test.opt) 179 | if err != nil { 180 | t.Fatal(err) 181 | } 182 | 183 | assert.Equal(t, test.expCommitIDs, commitsToIDs(commits)) 184 | }) 185 | } 186 | } 187 | 188 | func TestCommit_SearchCommits(t *testing.T) { 189 | tests := []struct { 190 | id string 191 | pattern string 192 | opt SearchCommitsOptions 193 | expCommitIDs []string 194 | }{ 195 | { 196 | id: "2a52e96389d02209b451ae1ddf45d645b42d744c", 197 | pattern: "", 198 | expCommitIDs: []string{ 199 | "2a52e96389d02209b451ae1ddf45d645b42d744c", 200 | "57d0bf61e57cdacb309ebd1075257c6bd7e1da81", 201 | "cb2d322bee073327e058143329d200024bd6b4c6", 202 | "818f033c4ae7f26b2b29e904942fa79a5ccaadd0", 203 | "369adba006a1bbf25e957a8622d2b919c994d035", 204 | "2956e1d20897bf6ed509f6429d7f64bc4823fe33", 205 | "333fd9bc94084c3e07e092e2bc9c22bab4476439", 206 | "f5ed01959cffa4758ca0a49bf4c34b138d7eab0a", 207 | "9cdb160ee4118035bf73c744e3bf72a1ba16484a", 208 | "dc64fe4ab8618a5be491a9fca46f1585585ea44e", 209 | "32c273781bab599b955ce7c59d92c39bedf35db0", 210 | "755fd577edcfd9209d0ac072eed3b022cbe4d39b", 211 | }, 212 | }, 213 | { 214 | id: "2a52e96389d02209b451ae1ddf45d645b42d744c", 215 | pattern: "", 216 | opt: SearchCommitsOptions{ 217 | MaxCount: 3, 218 | }, 219 | expCommitIDs: []string{ 220 | "2a52e96389d02209b451ae1ddf45d645b42d744c", 221 | "57d0bf61e57cdacb309ebd1075257c6bd7e1da81", 222 | "cb2d322bee073327e058143329d200024bd6b4c6", 223 | }, 224 | }, 225 | 226 | { 227 | id: "2a52e96389d02209b451ae1ddf45d645b42d744c", 228 | pattern: "feature", 229 | expCommitIDs: []string{ 230 | "2a52e96389d02209b451ae1ddf45d645b42d744c", 231 | "cb2d322bee073327e058143329d200024bd6b4c6", 232 | }, 233 | }, 234 | { 235 | id: "2a52e96389d02209b451ae1ddf45d645b42d744c", 236 | pattern: "feature", 237 | opt: SearchCommitsOptions{ 238 | MaxCount: 1, 239 | }, 240 | expCommitIDs: []string{ 241 | "2a52e96389d02209b451ae1ddf45d645b42d744c", 242 | }, 243 | }, 244 | 245 | { 246 | id: "2a52e96389d02209b451ae1ddf45d645b42d744c", 247 | pattern: "add.*", 248 | opt: SearchCommitsOptions{ 249 | Path: "src", 250 | }, 251 | expCommitIDs: []string{ 252 | "cb2d322bee073327e058143329d200024bd6b4c6", 253 | "818f033c4ae7f26b2b29e904942fa79a5ccaadd0", 254 | "333fd9bc94084c3e07e092e2bc9c22bab4476439", 255 | "32c273781bab599b955ce7c59d92c39bedf35db0", 256 | "755fd577edcfd9209d0ac072eed3b022cbe4d39b", 257 | }, 258 | }, 259 | { 260 | id: "2a52e96389d02209b451ae1ddf45d645b42d744c", 261 | pattern: "add.*", 262 | opt: SearchCommitsOptions{ 263 | MaxCount: 2, 264 | Path: "src", 265 | }, 266 | expCommitIDs: []string{ 267 | "cb2d322bee073327e058143329d200024bd6b4c6", 268 | "818f033c4ae7f26b2b29e904942fa79a5ccaadd0", 269 | }, 270 | }, 271 | } 272 | for _, test := range tests { 273 | t.Run("", func(t *testing.T) { 274 | c, err := testrepo.CatFileCommit(test.id) 275 | if err != nil { 276 | t.Fatal(err) 277 | } 278 | 279 | commits, err := c.SearchCommits(test.pattern, test.opt) 280 | if err != nil { 281 | t.Fatal(err) 282 | } 283 | 284 | assert.Equal(t, test.expCommitIDs, commitsToIDs(commits)) 285 | }) 286 | } 287 | } 288 | 289 | func TestCommit_ShowNameStatus(t *testing.T) { 290 | tests := []struct { 291 | id string 292 | opt ShowNameStatusOptions 293 | expStatus *NameStatus 294 | }{ 295 | { 296 | id: "755fd577edcfd9209d0ac072eed3b022cbe4d39b", 297 | expStatus: &NameStatus{ 298 | Added: []string{ 299 | "README.txt", 300 | "resources/labels.properties", 301 | "src/Main.groovy", 302 | }, 303 | }, 304 | }, 305 | { 306 | id: "32c273781bab599b955ce7c59d92c39bedf35db0", 307 | expStatus: &NameStatus{ 308 | Modified: []string{ 309 | "src/Main.groovy", 310 | }, 311 | }, 312 | }, 313 | { 314 | id: "dc64fe4ab8618a5be491a9fca46f1585585ea44e", 315 | expStatus: &NameStatus{ 316 | Added: []string{ 317 | "src/Square.groovy", 318 | }, 319 | Modified: []string{ 320 | "src/Main.groovy", 321 | }, 322 | }, 323 | }, 324 | { 325 | id: "978fb7f6388b49b532fbef8b856681cfa6fcaa0a", 326 | expStatus: &NameStatus{ 327 | Removed: []string{ 328 | "fix.txt", 329 | }, 330 | }, 331 | }, 332 | } 333 | for _, test := range tests { 334 | t.Run("", func(t *testing.T) { 335 | c, err := testrepo.CatFileCommit(test.id) 336 | if err != nil { 337 | t.Fatal(err) 338 | } 339 | 340 | status, err := c.ShowNameStatus(test.opt) 341 | if err != nil { 342 | t.Fatal(err) 343 | } 344 | 345 | assert.Equal(t, test.expStatus, status) 346 | }) 347 | } 348 | } 349 | 350 | func TestCommit_CommitsCount(t *testing.T) { 351 | tests := []struct { 352 | id string 353 | opt RevListCountOptions 354 | expCount int64 355 | }{ 356 | { 357 | id: "755fd577edcfd9209d0ac072eed3b022cbe4d39b", 358 | expCount: 1, 359 | }, 360 | { 361 | id: "f5ed01959cffa4758ca0a49bf4c34b138d7eab0a", 362 | expCount: 5, 363 | }, 364 | { 365 | id: "978fb7f6388b49b532fbef8b856681cfa6fcaa0a", 366 | expCount: 27, 367 | }, 368 | 369 | { 370 | id: "7c5ee6478d137417ae602140c615e33aed91887c", 371 | opt: RevListCountOptions{ 372 | Path: "README.txt", 373 | }, 374 | expCount: 3, 375 | }, 376 | { 377 | id: "7c5ee6478d137417ae602140c615e33aed91887c", 378 | opt: RevListCountOptions{ 379 | Path: "resources", 380 | }, 381 | expCount: 1, 382 | }, 383 | } 384 | for _, test := range tests { 385 | t.Run("", func(t *testing.T) { 386 | c, err := testrepo.CatFileCommit(test.id) 387 | if err != nil { 388 | t.Fatal(err) 389 | } 390 | 391 | count, err := c.CommitsCount(test.opt) 392 | if err != nil { 393 | t.Fatal(err) 394 | } 395 | 396 | assert.Equal(t, test.expCount, count) 397 | }) 398 | } 399 | } 400 | 401 | func TestCommit_FilesChangedAfter(t *testing.T) { 402 | tests := []struct { 403 | id string 404 | after string 405 | opt DiffNameOnlyOptions 406 | expFiles []string 407 | }{ 408 | { 409 | id: "978fb7f6388b49b532fbef8b856681cfa6fcaa0a", 410 | after: "ef7bebf8bdb1919d947afe46ab4b2fb4278039b3", 411 | expFiles: []string{"fix.txt"}, 412 | }, 413 | { 414 | id: "978fb7f6388b49b532fbef8b856681cfa6fcaa0a", 415 | after: "45a30ea9afa413e226ca8614179c011d545ca883", 416 | expFiles: []string{"fix.txt", "pom.xml", "src/test/java/com/github/AppTest.java"}, 417 | }, 418 | 419 | { 420 | id: "978fb7f6388b49b532fbef8b856681cfa6fcaa0a", 421 | after: "45a30ea9afa413e226ca8614179c011d545ca883", 422 | opt: DiffNameOnlyOptions{ 423 | Path: "src", 424 | }, 425 | expFiles: []string{"src/test/java/com/github/AppTest.java"}, 426 | }, 427 | { 428 | id: "978fb7f6388b49b532fbef8b856681cfa6fcaa0a", 429 | after: "45a30ea9afa413e226ca8614179c011d545ca883", 430 | opt: DiffNameOnlyOptions{ 431 | Path: "resources", 432 | }, 433 | expFiles: []string{}, 434 | }, 435 | } 436 | for _, test := range tests { 437 | t.Run("", func(t *testing.T) { 438 | c, err := testrepo.CatFileCommit(test.id) 439 | if err != nil { 440 | t.Fatal(err) 441 | } 442 | 443 | files, err := c.FilesChangedAfter(test.after, test.opt) 444 | if err != nil { 445 | t.Fatal(err) 446 | } 447 | 448 | assert.Equal(t, test.expFiles, files) 449 | }) 450 | } 451 | } 452 | 453 | func TestCommit_CommitsAfter(t *testing.T) { 454 | tests := []struct { 455 | id string 456 | after string 457 | opt RevListOptions 458 | expCommitIDs []string 459 | }{ 460 | { 461 | id: "978fb7f6388b49b532fbef8b856681cfa6fcaa0a", 462 | after: "45a30ea9afa413e226ca8614179c011d545ca883", 463 | expCommitIDs: []string{ 464 | "978fb7f6388b49b532fbef8b856681cfa6fcaa0a", 465 | "ef7bebf8bdb1919d947afe46ab4b2fb4278039b3", 466 | "ebbbf773431ba07510251bb03f9525c7bab2b13a", 467 | }, 468 | }, 469 | { 470 | id: "978fb7f6388b49b532fbef8b856681cfa6fcaa0a", 471 | after: "45a30ea9afa413e226ca8614179c011d545ca883", 472 | opt: RevListOptions{ 473 | Path: "src", 474 | }, 475 | expCommitIDs: []string{ 476 | "ebbbf773431ba07510251bb03f9525c7bab2b13a", 477 | }, 478 | }, 479 | } 480 | for _, test := range tests { 481 | t.Run("", func(t *testing.T) { 482 | c, err := testrepo.CatFileCommit(test.id) 483 | if err != nil { 484 | t.Fatal(err) 485 | } 486 | 487 | commits, err := c.CommitsAfter(test.after, test.opt) 488 | if err != nil { 489 | t.Fatal(err) 490 | } 491 | 492 | assert.Equal(t, test.expCommitIDs, commitsToIDs(commits)) 493 | }) 494 | } 495 | } 496 | 497 | func TestCommit_Ancestors(t *testing.T) { 498 | tests := []struct { 499 | id string 500 | opt LogOptions 501 | expCommitIDs []string 502 | }{ 503 | { 504 | id: "2a52e96389d02209b451ae1ddf45d645b42d744c", 505 | opt: LogOptions{ 506 | MaxCount: 3, 507 | }, 508 | expCommitIDs: []string{ 509 | "57d0bf61e57cdacb309ebd1075257c6bd7e1da81", 510 | "cb2d322bee073327e058143329d200024bd6b4c6", 511 | "818f033c4ae7f26b2b29e904942fa79a5ccaadd0", 512 | }, 513 | }, 514 | { 515 | id: "755fd577edcfd9209d0ac072eed3b022cbe4d39b", 516 | expCommitIDs: []string{}, 517 | }, 518 | } 519 | for _, test := range tests { 520 | t.Run("", func(t *testing.T) { 521 | c, err := testrepo.CatFileCommit(test.id) 522 | if err != nil { 523 | t.Fatal(err) 524 | } 525 | 526 | commits, err := c.Ancestors(test.opt) 527 | if err != nil { 528 | t.Fatal(err) 529 | } 530 | 531 | assert.Equal(t, test.expCommitIDs, commitsToIDs(commits)) 532 | }) 533 | } 534 | } 535 | 536 | func TestCommit_IsImageFile(t *testing.T) { 537 | t.Run("not a blob", func(t *testing.T) { 538 | c, err := testrepo.CatFileCommit("4e59b72440188e7c2578299fc28ea425fbe9aece") 539 | if err != nil { 540 | t.Fatal(err) 541 | } 542 | 543 | isImage, err := c.IsImageFile("gogs/docs-api") 544 | if err != nil { 545 | t.Fatal(err) 546 | } 547 | assert.False(t, isImage) 548 | }) 549 | 550 | tests := []struct { 551 | id string 552 | name string 553 | expVal bool 554 | }{ 555 | { 556 | id: "4eaa8d4b05e731e950e2eaf9e8b92f522303ab41", 557 | name: "README.txt", 558 | expVal: false, 559 | }, 560 | { 561 | id: "4eaa8d4b05e731e950e2eaf9e8b92f522303ab41", 562 | name: "img/sourcegraph.png", 563 | expVal: true, 564 | }, 565 | } 566 | for _, test := range tests { 567 | t.Run("", func(t *testing.T) { 568 | c, err := testrepo.CatFileCommit(test.id) 569 | if err != nil { 570 | t.Fatal(err) 571 | } 572 | 573 | isImage, err := c.IsImageFile(test.name) 574 | if err != nil { 575 | t.Fatal(err) 576 | } 577 | 578 | assert.Equal(t, test.expVal, isImage) 579 | }) 580 | } 581 | } 582 | 583 | func TestCommit_IsImageFileByIndex(t *testing.T) { 584 | t.Run("not a blob", func(t *testing.T) { 585 | c, err := testrepo.CatFileCommit("4e59b72440188e7c2578299fc28ea425fbe9aece") 586 | if err != nil { 587 | t.Fatal(err) 588 | } 589 | 590 | isImage, err := c.IsImageFileByIndex("fcf7087e732bfe3c25328248a9bf8c3ccd85bed4") // "gogs" 591 | if err != nil { 592 | t.Fatal(err) 593 | } 594 | assert.False(t, isImage) 595 | }) 596 | 597 | tests := []struct { 598 | id string 599 | index string 600 | expVal bool 601 | }{ 602 | { 603 | id: "4eaa8d4b05e731e950e2eaf9e8b92f522303ab41", 604 | index: "adfd6da3c0a3fb038393144becbf37f14f780087", // "README.txt" 605 | expVal: false, 606 | }, 607 | { 608 | id: "4eaa8d4b05e731e950e2eaf9e8b92f522303ab41", 609 | index: "2ce918888b0fdd4736767360fc5e3e83daf47fce", // "img/sourcegraph.png" 610 | expVal: true, 611 | }, 612 | } 613 | for _, test := range tests { 614 | t.Run("", func(t *testing.T) { 615 | c, err := testrepo.CatFileCommit(test.id) 616 | if err != nil { 617 | t.Fatal(err) 618 | } 619 | 620 | isImage, err := c.IsImageFileByIndex(test.index) 621 | if err != nil { 622 | t.Fatal(err) 623 | } 624 | 625 | assert.Equal(t, test.expVal, isImage) 626 | }) 627 | } 628 | } 629 | -------------------------------------------------------------------------------- /diff.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "bufio" 9 | "bytes" 10 | "errors" 11 | "fmt" 12 | "io" 13 | "io/ioutil" 14 | "strconv" 15 | "strings" 16 | ) 17 | 18 | // DiffLineType is the line type in diff. 19 | type DiffLineType uint8 20 | 21 | // A list of different line types. 22 | const ( 23 | DiffLinePlain DiffLineType = iota + 1 24 | DiffLineAdd 25 | DiffLineDelete 26 | DiffLineSection 27 | ) 28 | 29 | // DiffFileType is the file status in diff. 30 | type DiffFileType uint8 31 | 32 | // A list of different file statuses. 33 | const ( 34 | DiffFileAdd DiffFileType = iota + 1 35 | DiffFileChange 36 | DiffFileDelete 37 | DiffFileRename 38 | ) 39 | 40 | // DiffLine represents a line in diff. 41 | type DiffLine struct { 42 | Type DiffLineType // The type of the line 43 | Content string // The content of the line 44 | LeftLine int // The left line number 45 | RightLine int // The right line number 46 | } 47 | 48 | // DiffSection represents a section in diff. 49 | type DiffSection struct { 50 | Lines []*DiffLine // lines in the section 51 | 52 | numAdditions int 53 | numDeletions int 54 | } 55 | 56 | // NumLines returns the number of lines in the section. 57 | func (s *DiffSection) NumLines() int { 58 | return len(s.Lines) 59 | } 60 | 61 | // Line returns a specific line by given type and line number in a section. 62 | func (s *DiffSection) Line(typ DiffLineType, line int) *DiffLine { 63 | var ( 64 | difference = 0 65 | addCount = 0 66 | delCount = 0 67 | matchedDiffLine *DiffLine 68 | ) 69 | 70 | loop: 71 | for _, diffLine := range s.Lines { 72 | switch diffLine.Type { 73 | case DiffLineAdd: 74 | addCount++ 75 | case DiffLineDelete: 76 | delCount++ 77 | default: 78 | if matchedDiffLine != nil { 79 | break loop 80 | } 81 | difference = diffLine.RightLine - diffLine.LeftLine 82 | addCount = 0 83 | delCount = 0 84 | } 85 | 86 | switch typ { 87 | case DiffLineDelete: 88 | if diffLine.RightLine == 0 && diffLine.LeftLine == line-difference { 89 | matchedDiffLine = diffLine 90 | } 91 | case DiffLineAdd: 92 | if diffLine.LeftLine == 0 && diffLine.RightLine == line+difference { 93 | matchedDiffLine = diffLine 94 | } 95 | } 96 | } 97 | 98 | if addCount == delCount { 99 | return matchedDiffLine 100 | } 101 | return nil 102 | } 103 | 104 | // DiffFile represents a file in diff. 105 | type DiffFile struct { 106 | // The name of the file. 107 | Name string 108 | // The type of the file. 109 | Type DiffFileType 110 | // The index (SHA1 hash) of the file. For a changed/new file, it is the new SHA, 111 | // and for a deleted file it becomes "000000". 112 | Index string 113 | // OldIndex is the old index (SHA1 hash) of the file. 114 | OldIndex string 115 | // The sections in the file. 116 | Sections []*DiffSection 117 | 118 | numAdditions int 119 | numDeletions int 120 | 121 | oldName string 122 | 123 | mode EntryMode 124 | oldMode EntryMode 125 | 126 | isBinary bool 127 | isSubmodule bool 128 | isIncomplete bool 129 | } 130 | 131 | // NumSections returns the number of sections in the file. 132 | func (f *DiffFile) NumSections() int { 133 | return len(f.Sections) 134 | } 135 | 136 | // NumAdditions returns the number of additions in the file. 137 | func (f *DiffFile) NumAdditions() int { 138 | return f.numAdditions 139 | } 140 | 141 | // NumDeletions returns the number of deletions in the file. 142 | func (f *DiffFile) NumDeletions() int { 143 | return f.numDeletions 144 | } 145 | 146 | // IsCreated returns true if the file is newly created. 147 | func (f *DiffFile) IsCreated() bool { 148 | return f.Type == DiffFileAdd 149 | } 150 | 151 | // IsDeleted returns true if the file has been deleted. 152 | func (f *DiffFile) IsDeleted() bool { 153 | return f.Type == DiffFileDelete 154 | } 155 | 156 | // IsRenamed returns true if the file has been renamed. 157 | func (f *DiffFile) IsRenamed() bool { 158 | return f.Type == DiffFileRename 159 | } 160 | 161 | // OldName returns previous name before renaming. 162 | func (f *DiffFile) OldName() string { 163 | return f.oldName 164 | } 165 | 166 | // Mode returns the mode of the file. 167 | func (f *DiffFile) Mode() EntryMode { 168 | return f.mode 169 | } 170 | 171 | // OldMode returns the old mode of the file if it's changed. 172 | func (f *DiffFile) OldMode() EntryMode { 173 | return f.oldMode 174 | } 175 | 176 | // IsBinary returns true if the file is in binary format. 177 | func (f *DiffFile) IsBinary() bool { 178 | return f.isBinary 179 | } 180 | 181 | // IsSubmodule returns true if the file contains information of a submodule. 182 | func (f *DiffFile) IsSubmodule() bool { 183 | return f.isSubmodule 184 | } 185 | 186 | // IsIncomplete returns true if the file is incomplete to the file diff. 187 | func (f *DiffFile) IsIncomplete() bool { 188 | return f.isIncomplete 189 | } 190 | 191 | // Diff represents a Git diff. 192 | type Diff struct { 193 | Files []*DiffFile // The files in the diff 194 | 195 | totalAdditions int 196 | totalDeletions int 197 | 198 | isIncomplete bool 199 | } 200 | 201 | // NumFiles returns the number of files in the diff. 202 | func (d *Diff) NumFiles() int { 203 | return len(d.Files) 204 | } 205 | 206 | // TotalAdditions returns the total additions in the diff. 207 | func (d *Diff) TotalAdditions() int { 208 | return d.totalAdditions 209 | } 210 | 211 | // TotalDeletions returns the total deletions in the diff. 212 | func (d *Diff) TotalDeletions() int { 213 | return d.totalDeletions 214 | } 215 | 216 | // IsIncomplete returns true if the file is incomplete to the entire diff. 217 | func (d *Diff) IsIncomplete() bool { 218 | return d.isIncomplete 219 | } 220 | 221 | // SteamParseDiffResult contains results of streaming parsing a diff. 222 | type SteamParseDiffResult struct { 223 | Diff *Diff 224 | Err error 225 | } 226 | 227 | type diffParser struct { 228 | *bufio.Reader 229 | maxFiles int 230 | maxFileLines int 231 | maxLineChars int 232 | 233 | // The next line that hasn't been processed. It is used to determine what kind 234 | // of process should go in. 235 | buffer []byte 236 | isEOF bool 237 | } 238 | 239 | func (p *diffParser) readLine() error { 240 | if p.buffer != nil { 241 | return nil 242 | } 243 | 244 | var err error 245 | p.buffer, err = p.ReadBytes('\n') 246 | if err != nil { 247 | if err != io.EOF { 248 | return fmt.Errorf("read string: %v", err) 249 | } 250 | 251 | p.isEOF = true 252 | } 253 | 254 | // Remove line break 255 | if len(p.buffer) > 0 && p.buffer[len(p.buffer)-1] == '\n' { 256 | p.buffer = p.buffer[:len(p.buffer)-1] 257 | } 258 | return nil 259 | } 260 | 261 | var diffHead = []byte("diff --git ") 262 | 263 | func (p *diffParser) parseFileHeader() (*DiffFile, error) { 264 | line := string(p.buffer) 265 | p.buffer = nil 266 | 267 | // NOTE: In case file name is surrounded by double quotes (it happens only in 268 | // git-shell). e.g. diff --git "a/xxx" "b/xxx" 269 | var middle int 270 | hasQuote := line[len(diffHead)] == '"' 271 | if hasQuote { 272 | middle = strings.Index(line, ` "b/`) 273 | } else { 274 | middle = strings.Index(line, ` b/`) 275 | } 276 | 277 | beg := len(diffHead) 278 | a := line[beg+2 : middle] 279 | b := line[middle+3:] 280 | if hasQuote { 281 | a = string(UnescapeChars([]byte(a[1 : len(a)-1]))) 282 | b = string(UnescapeChars([]byte(b[1 : len(b)-1]))) 283 | } 284 | 285 | file := &DiffFile{ 286 | Name: a, 287 | oldName: b, 288 | Type: DiffFileChange, 289 | } 290 | 291 | // Check file diff type and submodule 292 | var err error 293 | checkType: 294 | for !p.isEOF { 295 | if err = p.readLine(); err != nil { 296 | return nil, err 297 | } 298 | 299 | line := string(p.buffer) 300 | p.buffer = nil 301 | 302 | if len(line) == 0 { 303 | continue 304 | } 305 | 306 | switch { 307 | case strings.HasPrefix(line, "new file"): 308 | file.Type = DiffFileAdd 309 | file.isSubmodule = strings.HasSuffix(line, " 160000") 310 | fields := strings.Fields(line) 311 | if len(fields) > 0 { 312 | mode, _ := strconv.ParseUint(fields[len(fields)-1], 8, 64) 313 | file.mode = EntryMode(mode) 314 | if file.oldMode == 0 { 315 | file.oldMode = file.mode 316 | } 317 | } 318 | case strings.HasPrefix(line, "deleted"): 319 | file.Type = DiffFileDelete 320 | file.isSubmodule = strings.HasSuffix(line, " 160000") 321 | fields := strings.Fields(line) 322 | if len(fields) > 0 { 323 | mode, _ := strconv.ParseUint(fields[len(fields)-1], 8, 64) 324 | file.mode = EntryMode(mode) 325 | if file.oldMode == 0 { 326 | file.oldMode = file.mode 327 | } 328 | } 329 | case strings.HasPrefix(line, "index"): // e.g. index ee791be..9997571 100644 330 | fields := strings.Fields(line[6:]) 331 | shas := strings.Split(fields[0], "..") 332 | if len(shas) != 2 { 333 | return nil, errors.New("malformed index: expect two SHAs in the form of ..") 334 | } 335 | 336 | file.OldIndex = shas[0] 337 | file.Index = shas[1] 338 | if len(fields) > 1 { 339 | mode, _ := strconv.ParseUint(fields[1], 8, 64) 340 | file.mode = EntryMode(mode) 341 | file.oldMode = EntryMode(mode) 342 | } 343 | break checkType 344 | case strings.HasPrefix(line, "similarity index "): 345 | file.Type = DiffFileRename 346 | file.oldName = a 347 | file.Name = b 348 | 349 | // No need to look for index if it's a pure rename 350 | if strings.HasSuffix(line, "100%") { 351 | break checkType 352 | } 353 | case strings.HasPrefix(line, "new mode"): 354 | fields := strings.Fields(line) 355 | if len(fields) > 0 { 356 | mode, _ := strconv.ParseUint(fields[len(fields)-1], 8, 64) 357 | file.mode = EntryMode(mode) 358 | } 359 | case strings.HasPrefix(line, "old mode"): 360 | fields := strings.Fields(line) 361 | if len(fields) > 0 { 362 | mode, _ := strconv.ParseUint(fields[len(fields)-1], 8, 64) 363 | file.oldMode = EntryMode(mode) 364 | } 365 | } 366 | } 367 | 368 | return file, nil 369 | } 370 | 371 | func (p *diffParser) parseSection() (_ *DiffSection, isIncomplete bool, _ error) { 372 | line := string(p.buffer) 373 | p.buffer = nil 374 | 375 | section := &DiffSection{ 376 | Lines: []*DiffLine{ 377 | { 378 | Type: DiffLineSection, 379 | Content: line, 380 | }, 381 | }, 382 | } 383 | 384 | // Parse line number, e.g. @@ -0,0 +1,3 @@ 385 | var leftLine, rightLine int 386 | ss := strings.Split(line, "@@") 387 | ranges := strings.Split(ss[1][1:], " ") 388 | leftLine, _ = strconv.Atoi(strings.Split(ranges[0], ",")[0][1:]) 389 | if len(ranges) > 1 { 390 | rightLine, _ = strconv.Atoi(strings.Split(ranges[1], ",")[0]) 391 | } else { 392 | rightLine = leftLine 393 | } 394 | 395 | var err error 396 | for !p.isEOF { 397 | if err = p.readLine(); err != nil { 398 | return nil, false, err 399 | } 400 | 401 | if len(p.buffer) == 0 { 402 | p.buffer = nil 403 | continue 404 | } 405 | 406 | // Make sure we're still in the section. If not, we're done with this section. 407 | if p.buffer[0] != ' ' && 408 | p.buffer[0] != '+' && 409 | p.buffer[0] != '-' { 410 | 411 | // No new line indicator 412 | if p.buffer[0] == '\\' && 413 | bytes.HasPrefix(p.buffer, []byte(`\ No newline at end of file`)) { 414 | p.buffer = nil 415 | continue 416 | } 417 | return section, false, nil 418 | } 419 | 420 | line := string(p.buffer) 421 | p.buffer = nil 422 | 423 | // Too many characters in a single diff line 424 | if p.maxLineChars > 0 && len(line) > p.maxLineChars { 425 | return section, true, nil 426 | } 427 | 428 | switch line[0] { 429 | case ' ': 430 | section.Lines = append(section.Lines, &DiffLine{ 431 | Type: DiffLinePlain, 432 | Content: line, 433 | LeftLine: leftLine, 434 | RightLine: rightLine, 435 | }) 436 | leftLine++ 437 | rightLine++ 438 | case '+': 439 | section.Lines = append(section.Lines, &DiffLine{ 440 | Type: DiffLineAdd, 441 | Content: line, 442 | RightLine: rightLine, 443 | }) 444 | section.numAdditions++ 445 | rightLine++ 446 | case '-': 447 | section.Lines = append(section.Lines, &DiffLine{ 448 | Type: DiffLineDelete, 449 | Content: line, 450 | LeftLine: leftLine, 451 | }) 452 | section.numDeletions++ 453 | if leftLine > 0 { 454 | leftLine++ 455 | } 456 | } 457 | } 458 | 459 | return section, false, nil 460 | } 461 | 462 | func (p *diffParser) parse() (*Diff, error) { 463 | diff := new(Diff) 464 | file := new(DiffFile) 465 | currentFileLines := 0 466 | 467 | var err error 468 | for !p.isEOF { 469 | if err = p.readLine(); err != nil { 470 | return nil, err 471 | } 472 | 473 | if len(p.buffer) == 0 || 474 | bytes.HasPrefix(p.buffer, []byte("+++ ")) || 475 | bytes.HasPrefix(p.buffer, []byte("--- ")) { 476 | p.buffer = nil 477 | continue 478 | } 479 | 480 | // Found new file 481 | if bytes.HasPrefix(p.buffer, diffHead) { 482 | // Check if reached maximum number of files 483 | if p.maxFiles > 0 && len(diff.Files) >= p.maxFiles { 484 | diff.isIncomplete = true 485 | _, _ = io.Copy(ioutil.Discard, p) 486 | break 487 | } 488 | 489 | file, err = p.parseFileHeader() 490 | if err != nil { 491 | return nil, err 492 | } 493 | diff.Files = append(diff.Files, file) 494 | 495 | currentFileLines = 0 496 | continue 497 | } 498 | 499 | if file == nil || file.isIncomplete { 500 | p.buffer = nil 501 | continue 502 | } 503 | 504 | if bytes.HasPrefix(p.buffer, []byte("Binary")) { 505 | p.buffer = nil 506 | file.isBinary = true 507 | continue 508 | } 509 | 510 | // Loop until we found section header 511 | if p.buffer[0] != '@' { 512 | p.buffer = nil 513 | continue 514 | } 515 | 516 | // Too many diff lines for the file 517 | if p.maxFileLines > 0 && currentFileLines > p.maxFileLines { 518 | file.isIncomplete = true 519 | diff.isIncomplete = true 520 | continue 521 | } 522 | 523 | section, isIncomplete, err := p.parseSection() 524 | if err != nil { 525 | return nil, err 526 | } 527 | file.Sections = append(file.Sections, section) 528 | file.numAdditions += section.numAdditions 529 | file.numDeletions += section.numDeletions 530 | diff.totalAdditions += section.numAdditions 531 | diff.totalDeletions += section.numDeletions 532 | currentFileLines += section.NumLines() 533 | if isIncomplete { 534 | file.isIncomplete = true 535 | diff.isIncomplete = true 536 | } 537 | } 538 | 539 | return diff, nil 540 | } 541 | 542 | // StreamParseDiff parses the diff read from the given io.Reader. It does 543 | // parse-on-read to minimize the time spent on huge diffs. It accepts a channel 544 | // to notify and send error (if any) to the caller when the process is done. 545 | // Therefore, this method should be called in a goroutine asynchronously. 546 | func StreamParseDiff(r io.Reader, done chan<- SteamParseDiffResult, maxFiles, maxFileLines, maxLineChars int) { 547 | p := &diffParser{ 548 | Reader: bufio.NewReader(r), 549 | maxFiles: maxFiles, 550 | maxFileLines: maxFileLines, 551 | maxLineChars: maxLineChars, 552 | } 553 | diff, err := p.parse() 554 | done <- SteamParseDiffResult{ 555 | Diff: diff, 556 | Err: err, 557 | } 558 | } 559 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "errors" 9 | ) 10 | 11 | var ( 12 | ErrParentNotExist = errors.New("parent does not exist") 13 | ErrSubmoduleNotExist = errors.New("submodule does not exist") 14 | ErrRevisionNotExist = errors.New("revision does not exist") 15 | ErrRemoteNotExist = errors.New("remote does not exist") 16 | ErrURLNotExist = errors.New("URL does not exist") 17 | ErrExecTimeout = errors.New("execution was timed out") 18 | ErrNoMergeBase = errors.New("no merge based was found") 19 | ErrNotBlob = errors.New("the entry is not a blob") 20 | ErrNotDeleteNonPushURLs = errors.New("will not delete all non-push URLs") 21 | ) 22 | -------------------------------------------------------------------------------- /git.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "fmt" 9 | "io" 10 | "strings" 11 | "sync" 12 | ) 13 | 14 | var ( 15 | // logOutput is the writer to write logs. When not set, no log will be produced. 16 | logOutput io.Writer 17 | // logPrefix is the prefix prepend to each log entry. 18 | logPrefix = "[git-module] " 19 | ) 20 | 21 | // SetOutput sets the output writer for logs. 22 | func SetOutput(output io.Writer) { 23 | logOutput = output 24 | } 25 | 26 | // SetPrefix sets the prefix to be prepended to each log entry. 27 | func SetPrefix(prefix string) { 28 | logPrefix = prefix 29 | } 30 | 31 | func log(format string, args ...interface{}) { 32 | if logOutput == nil { 33 | return 34 | } 35 | 36 | _, _ = fmt.Fprint(logOutput, logPrefix) 37 | _, _ = fmt.Fprintf(logOutput, format, args...) 38 | _, _ = fmt.Fprintln(logOutput) 39 | } 40 | 41 | var ( 42 | // gitVersion stores the Git binary version. 43 | // NOTE: To check Git version should call BinVersion not this global variable. 44 | gitVersion string 45 | gitVersionOnce sync.Once 46 | gitVersionErr error 47 | ) 48 | 49 | // BinVersion returns current Git binary version that is used by this module. 50 | func BinVersion() (string, error) { 51 | gitVersionOnce.Do(func() { 52 | var stdout []byte 53 | stdout, gitVersionErr = NewCommand("version").Run() 54 | if gitVersionErr != nil { 55 | return 56 | } 57 | 58 | fields := strings.Fields(string(stdout)) 59 | if len(fields) < 3 { 60 | gitVersionErr = fmt.Errorf("not enough output: %s", stdout) 61 | return 62 | } 63 | 64 | // Handle special case on Windows. 65 | i := strings.Index(fields[2], "windows") 66 | if i >= 1 { 67 | gitVersion = fields[2][:i-1] 68 | return 69 | } 70 | 71 | gitVersion = fields[2] 72 | }) 73 | 74 | return gitVersion, gitVersionErr 75 | } 76 | -------------------------------------------------------------------------------- /git_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "bytes" 9 | "flag" 10 | "fmt" 11 | stdlog "log" 12 | "os" 13 | "testing" 14 | 15 | goversion "github.com/mcuadros/go-version" 16 | "github.com/stretchr/testify/assert" 17 | "golang.org/x/sync/errgroup" 18 | ) 19 | 20 | const repoPath = "testdata/testrepo.git" 21 | 22 | var testrepo *Repository 23 | 24 | func TestMain(m *testing.M) { 25 | flag.Parse() 26 | 27 | if testing.Verbose() { 28 | SetOutput(os.Stdout) 29 | } 30 | 31 | // Set up the test repository 32 | if !isExist(repoPath) { 33 | if err := Clone("https://github.com/gogs/git-module-testrepo.git", repoPath, CloneOptions{ 34 | Bare: true, 35 | }); err != nil { 36 | stdlog.Fatal(err) 37 | } 38 | } 39 | 40 | var err error 41 | testrepo, err = Open(repoPath) 42 | if err != nil { 43 | stdlog.Fatal(err) 44 | } 45 | 46 | os.Exit(m.Run()) 47 | } 48 | 49 | func TestSetPrefix(t *testing.T) { 50 | old := logPrefix 51 | new := "[custom] " 52 | SetPrefix(new) 53 | defer SetPrefix(old) 54 | 55 | assert.Equal(t, new, logPrefix) 56 | } 57 | 58 | func Test_log(t *testing.T) { 59 | old := logOutput 60 | defer SetOutput(old) 61 | 62 | tests := []struct { 63 | format string 64 | args []interface{} 65 | expOutput string 66 | }{ 67 | { 68 | format: "", 69 | expOutput: "[git-module] \n", 70 | }, 71 | { 72 | format: "something", 73 | expOutput: "[git-module] something\n", 74 | }, 75 | { 76 | format: "val: %v", 77 | args: []interface{}{123}, 78 | expOutput: "[git-module] val: 123\n", 79 | }, 80 | } 81 | for _, test := range tests { 82 | t.Run("", func(t *testing.T) { 83 | var buf bytes.Buffer 84 | SetOutput(&buf) 85 | 86 | log(test.format, test.args...) 87 | assert.Equal(t, test.expOutput, buf.String()) 88 | }) 89 | } 90 | } 91 | 92 | func TestBinVersion(t *testing.T) { 93 | g := errgroup.Group{} 94 | for i := 0; i < 30; i++ { 95 | g.Go(func() error { 96 | version, err := BinVersion() 97 | assert.Nil(t, err) 98 | 99 | if !goversion.Compare(version, "1.8.3", ">=") { 100 | return fmt.Errorf("version: expected >= 1.8.3 but got %q", version) 101 | } 102 | return nil 103 | }) 104 | } 105 | if err := g.Wait(); err != nil { 106 | t.Fatal(err) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gogs/git-module 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 7 | github.com/stretchr/testify v1.10.0 8 | golang.org/x/sync v0.14.0 9 | ) 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | gopkg.in/yaml.v3 v3.0.1 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /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/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 h1:Pijfgr7ZuvX7QIQiEwLdRVr3RoMG+i0SbBO1Qu+7yVk= 4 | github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 8 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 9 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 10 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 14 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 15 | -------------------------------------------------------------------------------- /hook.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "io/ioutil" 9 | "os" 10 | "path" 11 | "strings" 12 | ) 13 | 14 | // HookName is the name of a Git hook. 15 | type HookName string 16 | 17 | // A list of Git server hooks' name that are supported. 18 | const ( 19 | HookPreReceive HookName = "pre-receive" 20 | HookUpdate HookName = "update" 21 | HookPostReceive HookName = "post-receive" 22 | ) 23 | 24 | var ( 25 | // ServerSideHooks contains a list of Git hooks that are supported on the server 26 | // side. 27 | ServerSideHooks = []HookName{HookPreReceive, HookUpdate, HookPostReceive} 28 | // ServerSideHookSamples contains samples of Git hooks that are supported on the 29 | // server side. 30 | ServerSideHookSamples = map[HookName]string{ 31 | HookPreReceive: `#!/bin/sh 32 | # 33 | # An example hook script to make use of push options. 34 | # The example simply echoes all push options that start with 'echoback=' 35 | # and rejects all pushes when the "reject" push option is used. 36 | # 37 | # To enable this hook, rename this file to "pre-receive". 38 | 39 | if test -n "$GIT_PUSH_OPTION_COUNT" 40 | then 41 | i=0 42 | while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" 43 | do 44 | eval "value=\$GIT_PUSH_OPTION_$i" 45 | case "$value" in 46 | echoback=*) 47 | echo "echo from the pre-receive-hook: ${value#*=}" >&2 48 | ;; 49 | reject) 50 | exit 1 51 | esac 52 | i=$((i + 1)) 53 | done 54 | fi 55 | `, 56 | HookUpdate: `#!/bin/sh 57 | # 58 | # An example hook script to block unannotated tags from entering. 59 | # Called by "git receive-pack" with arguments: refname sha1-old sha1-new 60 | # 61 | # To enable this hook, rename this file to "update". 62 | # 63 | # Config 64 | # ------ 65 | # hooks.allowunannotated 66 | # This boolean sets whether unannotated tags will be allowed into the 67 | # repository. By default they won't be. 68 | # hooks.allowdeletetag 69 | # This boolean sets whether deleting tags will be allowed in the 70 | # repository. By default they won't be. 71 | # hooks.allowmodifytag 72 | # This boolean sets whether a tag may be modified after creation. By default 73 | # it won't be. 74 | # hooks.allowdeletebranch 75 | # This boolean sets whether deleting branches will be allowed in the 76 | # repository. By default they won't be. 77 | # hooks.denycreatebranch 78 | # This boolean sets whether remotely creating branches will be denied 79 | # in the repository. By default this is allowed. 80 | # 81 | 82 | # --- Command line 83 | refname="$1" 84 | oldrev="$2" 85 | newrev="$3" 86 | 87 | # --- Safety check 88 | if [ -z "$GIT_DIR" ]; then 89 | echo "Don't run this script from the command line." >&2 90 | echo " (if you want, you could supply GIT_DIR then run" >&2 91 | echo " $0 )" >&2 92 | exit 1 93 | fi 94 | 95 | if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then 96 | echo "usage: $0 " >&2 97 | exit 1 98 | fi 99 | 100 | # --- Config 101 | allowunannotated=$(git config --bool hooks.allowunannotated) 102 | allowdeletebranch=$(git config --bool hooks.allowdeletebranch) 103 | denycreatebranch=$(git config --bool hooks.denycreatebranch) 104 | allowdeletetag=$(git config --bool hooks.allowdeletetag) 105 | allowmodifytag=$(git config --bool hooks.allowmodifytag) 106 | 107 | # check for no description 108 | projectdesc=$(sed -e '1q' "$GIT_DIR/description") 109 | case "$projectdesc" in 110 | "Unnamed repository"* | "") 111 | echo "*** Project description file hasn't been set" >&2 112 | exit 1 113 | ;; 114 | esac 115 | 116 | # --- Check types 117 | # if $newrev is 0000...0000, it's a commit to delete a ref. 118 | zero="0000000000000000000000000000000000000000" 119 | if [ "$newrev" = "$zero" ]; then 120 | newrev_type=delete 121 | else 122 | newrev_type=$(git cat-file -t $newrev) 123 | fi 124 | 125 | case "$refname","$newrev_type" in 126 | refs/tags/*,commit) 127 | # un-annotated tag 128 | short_refname=${refname##refs/tags/} 129 | if [ "$allowunannotated" != "true" ]; then 130 | echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2 131 | echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 132 | exit 1 133 | fi 134 | ;; 135 | refs/tags/*,delete) 136 | # delete tag 137 | if [ "$allowdeletetag" != "true" ]; then 138 | echo "*** Deleting a tag is not allowed in this repository" >&2 139 | exit 1 140 | fi 141 | ;; 142 | refs/tags/*,tag) 143 | # annotated tag 144 | if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 145 | then 146 | echo "*** Tag '$refname' already exists." >&2 147 | echo "*** Modifying a tag is not allowed in this repository." >&2 148 | exit 1 149 | fi 150 | ;; 151 | refs/heads/*,commit) 152 | # branch 153 | if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then 154 | echo "*** Creating a branch is not allowed in this repository" >&2 155 | exit 1 156 | fi 157 | ;; 158 | refs/heads/*,delete) 159 | # delete branch 160 | if [ "$allowdeletebranch" != "true" ]; then 161 | echo "*** Deleting a branch is not allowed in this repository" >&2 162 | exit 1 163 | fi 164 | ;; 165 | refs/remotes/*,commit) 166 | # tracking branch 167 | ;; 168 | refs/remotes/*,delete) 169 | # delete tracking branch 170 | if [ "$allowdeletebranch" != "true" ]; then 171 | echo "*** Deleting a tracking branch is not allowed in this repository" >&2 172 | exit 1 173 | fi 174 | ;; 175 | *) 176 | # Anything else (is there anything else?) 177 | echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 178 | exit 1 179 | ;; 180 | esac 181 | 182 | # --- Finished 183 | exit 0 184 | `, 185 | HookPostReceive: `#!/bin/sh 186 | # 187 | # An example hook script for the "post-receive" event. 188 | # 189 | # The "post-receive" script is run after receive-pack has accepted a pack 190 | # and the repository has been updated. It is passed arguments in through 191 | # stdin in the form 192 | # 193 | # For example: 194 | # aa453216d1b3e49e7f6f98441fa56946ddcd6a20 68f7abf4e6f922807889f52bc043ecd31b79f814 refs/heads/master 195 | 196 | while read oldrev newrev refname 197 | do 198 | branch=$(git rev-parse --symbolic --abbrev-ref $refname) 199 | if [ "master" = "$branch" ]; then 200 | # Do something 201 | fi 202 | done`, 203 | } 204 | ) 205 | 206 | // Hook contains information of a Git hook. 207 | type Hook struct { 208 | name HookName 209 | path string // The absolute file path of the hook. 210 | isSample bool // Indicates whether this hook is read from the sample. 211 | content string // The content of the hook. 212 | } 213 | 214 | // Name returns the name of the Git hook. 215 | func (h *Hook) Name() HookName { 216 | return h.name 217 | } 218 | 219 | // Path returns the path of the Git hook. 220 | func (h *Hook) Path() string { 221 | return h.path 222 | } 223 | 224 | // IsSample returns true if the content is read from the sample hook. 225 | func (h *Hook) IsSample() bool { 226 | return h.isSample 227 | } 228 | 229 | // Content returns the content of the Git hook. 230 | func (h *Hook) Content() string { 231 | return h.content 232 | } 233 | 234 | // Update writes the content of the Git hook on filesystem. It updates the 235 | // memory copy of the content as well. 236 | func (h *Hook) Update(content string) error { 237 | h.content = strings.TrimSpace(content) 238 | h.content = strings.Replace(h.content, "\r", "", -1) 239 | 240 | if err := os.MkdirAll(path.Dir(h.path), os.ModePerm); err != nil { 241 | return err 242 | } else if err = ioutil.WriteFile(h.path, []byte(h.content), os.ModePerm); err != nil { 243 | return err 244 | } 245 | 246 | h.isSample = false 247 | return nil 248 | } 249 | -------------------------------------------------------------------------------- /hook_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "io/ioutil" 9 | "os" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestHook(t *testing.T) { 16 | path := tempPath() 17 | h := &Hook{ 18 | name: HookPreReceive, 19 | path: path, 20 | isSample: false, 21 | content: "test content", 22 | } 23 | 24 | assert.Equal(t, HookPreReceive, h.Name()) 25 | assert.Equal(t, path, h.Path()) 26 | assert.False(t, h.IsSample()) 27 | assert.Equal(t, "test content", h.Content()) 28 | } 29 | 30 | func TestHook_Update(t *testing.T) { 31 | path := tempPath() 32 | defer func() { 33 | _ = os.Remove(path) 34 | }() 35 | 36 | h := &Hook{ 37 | name: HookPreReceive, 38 | path: path, 39 | isSample: false, 40 | } 41 | err := h.Update("test content") 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | p, err := ioutil.ReadFile(path) 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | assert.Equal(t, "test content", string(p)) 51 | } 52 | -------------------------------------------------------------------------------- /object.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | // ObjectType is the type of a Git objet. 8 | type ObjectType string 9 | 10 | // A list of object types. 11 | const ( 12 | ObjectCommit ObjectType = "commit" 13 | ObjectTree ObjectType = "tree" 14 | ObjectBlob ObjectType = "blob" 15 | ObjectTag ObjectType = "tag" 16 | ) 17 | -------------------------------------------------------------------------------- /repo_blame.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "bytes" 9 | "time" 10 | ) 11 | 12 | // BlameOptions contains optional arguments for blaming a file. 13 | // Docs: https://git-scm.com/docs/git-blame 14 | type BlameOptions struct { 15 | // The timeout duration before giving up for each shell command execution. The 16 | // default timeout duration will be used when not supplied. 17 | // 18 | // Deprecated: Use CommandOptions.Timeout instead. 19 | Timeout time.Duration 20 | // The additional options to be passed to the underlying git. 21 | CommandOptions 22 | } 23 | 24 | // BlameFile returns blame results of the file with the given revision of the 25 | // repository. 26 | func (r *Repository) BlameFile(rev, file string, opts ...BlameOptions) (*Blame, error) { 27 | var opt BlameOptions 28 | if len(opts) > 0 { 29 | opt = opts[0] 30 | } 31 | 32 | stdout, err := NewCommand("blame"). 33 | AddOptions(opt.CommandOptions). 34 | AddArgs("-l", "-s", rev, "--", file). 35 | RunInDirWithTimeout(opt.Timeout, r.path) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | lines := bytes.Split(stdout, []byte{'\n'}) 41 | blame := &Blame{ 42 | lines: make([]*Commit, 0, len(lines)), 43 | } 44 | for _, line := range lines { 45 | if len(line) < 40 { 46 | break 47 | } 48 | id := line[:40] 49 | 50 | // Earliest commit is indicated by a leading "^" 51 | if id[0] == '^' { 52 | id = id[1:] 53 | } 54 | commit, err := r.CatFileCommit(string(id), CatFileCommitOptions{Timeout: opt.Timeout}) //nolint 55 | if err != nil { 56 | return nil, err 57 | } 58 | blame.lines = append(blame.lines, commit) 59 | } 60 | return blame, nil 61 | } 62 | -------------------------------------------------------------------------------- /repo_blame_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "fmt" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestRepository_BlameFile(t *testing.T) { 15 | t.Run("bad file", func(t *testing.T) { 16 | _, err := testrepo.BlameFile("", "404.txt") 17 | assert.Error(t, err) 18 | }) 19 | 20 | blame, err := testrepo.BlameFile("cfc3b2993f74726356887a5ec093de50486dc617", "README.txt") 21 | assert.Nil(t, err) 22 | 23 | // Assert representative commits 24 | // https://github.com/gogs/git-module-testrepo/blame/master/README.txt 25 | tests := []struct { 26 | line int 27 | expID string 28 | }{ 29 | {line: 1, expID: "755fd577edcfd9209d0ac072eed3b022cbe4d39b"}, 30 | {line: 3, expID: "a13dba1e469944772490909daa58c53ac8fa4b0d"}, 31 | {line: 5, expID: "755fd577edcfd9209d0ac072eed3b022cbe4d39b"}, 32 | {line: 13, expID: "8d2636da55da593c421e1cb09eea502a05556a69"}, 33 | } 34 | for _, test := range tests { 35 | t.Run(fmt.Sprintf("Line %d", test.line), func(t *testing.T) { 36 | line := blame.Line(test.line) 37 | assert.Equal(t, test.expID, line.ID.String()) 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /repo_blob.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import "time" 8 | 9 | // CatFileBlobOptions contains optional arguments for verifying the objects. 10 | // 11 | // Docs: https://git-scm.com/docs/git-cat-file#Documentation/git-cat-file.txt 12 | type CatFileBlobOptions struct { 13 | // The timeout duration before giving up for each shell command execution. 14 | // The default timeout duration will be used when not supplied. 15 | // 16 | // Deprecated: Use CommandOptions.Timeout instead. 17 | Timeout time.Duration 18 | // The additional options to be passed to the underlying git. 19 | CommandOptions 20 | } 21 | 22 | // CatFileBlob returns the blob corresponding to the given revision of the repository. 23 | func (r *Repository) CatFileBlob(rev string, opts ...CatFileBlobOptions) (*Blob, error) { 24 | var opt CatFileBlobOptions 25 | if len(opts) > 0 { 26 | opt = opts[0] 27 | } 28 | 29 | rev, err := r.RevParse(rev, RevParseOptions{Timeout: opt.Timeout}) //nolint 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | typ, err := r.CatFileType(rev) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | if typ != ObjectBlob { 40 | return nil, ErrNotBlob 41 | } 42 | 43 | return &Blob{ 44 | TreeEntry: &TreeEntry{ 45 | mode: EntryBlob, 46 | typ: ObjectBlob, 47 | id: MustIDFromString(rev), 48 | parent: &Tree{ 49 | repo: r, 50 | }, 51 | }, 52 | }, nil 53 | } 54 | -------------------------------------------------------------------------------- /repo_blob_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestRepository_CatFileBlob(t *testing.T) { 15 | t.Run("not a blob", func(t *testing.T) { 16 | _, err := testrepo.CatFileBlob("007cb92318c7bd3b56908ea8c2e54370245562f8") 17 | assert.Equal(t, ErrNotBlob, err) 18 | }) 19 | 20 | t.Run("get a blob, no full rev hash", func(t *testing.T) { 21 | b, err := testrepo.CatFileBlob("021a") 22 | require.NoError(t, err) 23 | assert.True(t, b.IsBlob()) 24 | }) 25 | 26 | t.Run("get a blob", func(t *testing.T) { 27 | b, err := testrepo.CatFileBlob("021a721a61a1de65865542c405796d1eb985f784") 28 | require.NoError(t, err) 29 | assert.True(t, b.IsBlob()) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /repo_commit_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "errors" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func Test_escapePath(t *testing.T) { 16 | tests := []struct { 17 | path string 18 | expPath string 19 | }{ 20 | { 21 | path: "", 22 | expPath: "", 23 | }, 24 | { 25 | path: "normal", 26 | expPath: "normal", 27 | }, 28 | { 29 | path: ":normal", 30 | expPath: "\\:normal", 31 | }, 32 | } 33 | for _, test := range tests { 34 | t.Run("", func(t *testing.T) { 35 | assert.Equal(t, test.expPath, escapePath(test.path)) 36 | }) 37 | } 38 | } 39 | 40 | func TestRepository_CatFileCommit(t *testing.T) { 41 | t.Run("invalid revision", func(t *testing.T) { 42 | c, err := testrepo.CatFileCommit("bad_revision") 43 | assert.Equal(t, ErrRevisionNotExist, err) 44 | assert.Nil(t, c) 45 | }) 46 | 47 | c, err := testrepo.CatFileCommit("d58e3ef9f123eea6857161c79275ee22b228f659") 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | 52 | assert.Equal(t, "d58e3ef9f123eea6857161c79275ee22b228f659", c.ID.String()) 53 | assert.Equal(t, "Add a symlink\n", c.Message) 54 | } 55 | 56 | func TestRepository_BranchCommit(t *testing.T) { 57 | t.Run("invalid branch", func(t *testing.T) { 58 | c, err := testrepo.BranchCommit("refs/heads/release-1.0") 59 | assert.Equal(t, ErrRevisionNotExist, err) 60 | assert.Nil(t, c) 61 | }) 62 | 63 | c, err := testrepo.BranchCommit("release-1.0") 64 | if err != nil { 65 | t.Fatal(err) 66 | } 67 | 68 | assert.Equal(t, "0eedd79eba4394bbef888c804e899731644367fe", c.ID.String()) 69 | assert.Equal(t, "Rename shell script\n", c.Message) 70 | } 71 | 72 | func TestRepository_TagCommit(t *testing.T) { 73 | t.Run("invalid branch", func(t *testing.T) { 74 | c, err := testrepo.BranchCommit("refs/tags/v1.0.0") 75 | assert.Equal(t, ErrRevisionNotExist, err) 76 | assert.Nil(t, c) 77 | }) 78 | 79 | c, err := testrepo.BranchCommit("release-1.0") 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | 84 | assert.Equal(t, "0eedd79eba4394bbef888c804e899731644367fe", c.ID.String()) 85 | assert.Equal(t, "Rename shell script\n", c.Message) 86 | } 87 | 88 | func TestRepository_Log(t *testing.T) { 89 | tests := []struct { 90 | rev string 91 | opt LogOptions 92 | expCommitIDs []string 93 | }{ 94 | { 95 | rev: "0eedd79eba4394bbef888c804e899731644367fe", 96 | opt: LogOptions{ 97 | Since: time.Unix(1581250680, 0), 98 | }, 99 | expCommitIDs: []string{ 100 | "0eedd79eba4394bbef888c804e899731644367fe", 101 | "4e59b72440188e7c2578299fc28ea425fbe9aece", 102 | }, 103 | }, 104 | { 105 | rev: "0eedd79eba4394bbef888c804e899731644367fe", 106 | opt: LogOptions{ 107 | Since: time.Now().AddDate(100, 0, 0), 108 | }, 109 | expCommitIDs: []string{}, 110 | }, 111 | } 112 | for _, test := range tests { 113 | t.Run("", func(t *testing.T) { 114 | commits, err := testrepo.Log(test.rev, test.opt) 115 | if err != nil { 116 | t.Fatal(err) 117 | } 118 | 119 | assert.Equal(t, test.expCommitIDs, commitsToIDs(commits)) 120 | 121 | commits, err = Log(testrepo.path, test.rev, test.opt) 122 | if err != nil { 123 | t.Fatal(err) 124 | } 125 | 126 | assert.Equal(t, test.expCommitIDs, commitsToIDs(commits)) 127 | }) 128 | } 129 | } 130 | 131 | func TestRepository_CommitByRevision(t *testing.T) { 132 | t.Run("invalid revision", func(t *testing.T) { 133 | c, err := testrepo.CommitByRevision("bad_revision") 134 | assert.Equal(t, ErrRevisionNotExist, err) 135 | assert.Nil(t, c) 136 | }) 137 | 138 | tests := []struct { 139 | rev string 140 | opt CommitByRevisionOptions 141 | expID string 142 | }{ 143 | { 144 | rev: "4e59b72", 145 | expID: "4e59b72440188e7c2578299fc28ea425fbe9aece", 146 | }, 147 | } 148 | for _, test := range tests { 149 | t.Run("", func(t *testing.T) { 150 | c, err := testrepo.CommitByRevision(test.rev, test.opt) 151 | if err != nil { 152 | t.Fatal(err) 153 | } 154 | 155 | assert.Equal(t, test.expID, c.ID.String()) 156 | }) 157 | } 158 | } 159 | 160 | func TestRepository_CommitsSince(t *testing.T) { 161 | tests := []struct { 162 | rev string 163 | since time.Time 164 | opt CommitsSinceOptions 165 | expCommitIDs []string 166 | }{ 167 | { 168 | rev: "0eedd79eba4394bbef888c804e899731644367fe", 169 | since: time.Unix(1581250680, 0), 170 | expCommitIDs: []string{ 171 | "0eedd79eba4394bbef888c804e899731644367fe", 172 | "4e59b72440188e7c2578299fc28ea425fbe9aece", 173 | }, 174 | }, 175 | { 176 | rev: "0eedd79eba4394bbef888c804e899731644367fe", 177 | since: time.Now().AddDate(100, 0, 0), 178 | expCommitIDs: []string{}, 179 | }, 180 | } 181 | for _, test := range tests { 182 | t.Run("", func(t *testing.T) { 183 | commits, err := testrepo.CommitsSince(test.rev, test.since, test.opt) 184 | if err != nil { 185 | t.Fatal(err) 186 | } 187 | 188 | assert.Equal(t, test.expCommitIDs, commitsToIDs(commits)) 189 | }) 190 | } 191 | } 192 | 193 | func TestRepository_DiffNameOnly(t *testing.T) { 194 | tests := []struct { 195 | base string 196 | head string 197 | opt DiffNameOnlyOptions 198 | expFiles []string 199 | }{ 200 | { 201 | base: "ef7bebf8bdb1919d947afe46ab4b2fb4278039b3", 202 | head: "978fb7f6388b49b532fbef8b856681cfa6fcaa0a", 203 | expFiles: []string{"fix.txt"}, 204 | }, 205 | { 206 | base: "45a30ea9afa413e226ca8614179c011d545ca883", 207 | head: "978fb7f6388b49b532fbef8b856681cfa6fcaa0a", 208 | opt: DiffNameOnlyOptions{ 209 | NeedsMergeBase: true, 210 | }, 211 | expFiles: []string{"fix.txt", "pom.xml", "src/test/java/com/github/AppTest.java"}, 212 | }, 213 | 214 | { 215 | base: "45a30ea9afa413e226ca8614179c011d545ca883", 216 | head: "978fb7f6388b49b532fbef8b856681cfa6fcaa0a", 217 | opt: DiffNameOnlyOptions{ 218 | Path: "src", 219 | }, 220 | expFiles: []string{"src/test/java/com/github/AppTest.java"}, 221 | }, 222 | { 223 | base: "45a30ea9afa413e226ca8614179c011d545ca883", 224 | head: "978fb7f6388b49b532fbef8b856681cfa6fcaa0a", 225 | opt: DiffNameOnlyOptions{ 226 | Path: "resources", 227 | }, 228 | expFiles: []string{}, 229 | }, 230 | } 231 | for _, test := range tests { 232 | t.Run("", func(t *testing.T) { 233 | files, err := testrepo.DiffNameOnly(test.base, test.head, test.opt) 234 | if err != nil { 235 | t.Fatal(err) 236 | } 237 | 238 | assert.Equal(t, test.expFiles, files) 239 | }) 240 | } 241 | } 242 | 243 | func TestRepository_RevListCount(t *testing.T) { 244 | t.Run("no refspecs", func(t *testing.T) { 245 | count, err := testrepo.RevListCount([]string{}) 246 | assert.Equal(t, errors.New("must have at least one refspec"), err) 247 | assert.Zero(t, count) 248 | }) 249 | 250 | tests := []struct { 251 | refspecs []string 252 | opt RevListCountOptions 253 | expCount int64 254 | }{ 255 | { 256 | refspecs: []string{"755fd577edcfd9209d0ac072eed3b022cbe4d39b"}, 257 | expCount: 1, 258 | }, 259 | { 260 | refspecs: []string{"f5ed01959cffa4758ca0a49bf4c34b138d7eab0a"}, 261 | expCount: 5, 262 | }, 263 | { 264 | refspecs: []string{"978fb7f6388b49b532fbef8b856681cfa6fcaa0a"}, 265 | expCount: 27, 266 | }, 267 | 268 | { 269 | refspecs: []string{"7c5ee6478d137417ae602140c615e33aed91887c"}, 270 | opt: RevListCountOptions{ 271 | Path: "README.txt", 272 | }, 273 | expCount: 3, 274 | }, 275 | { 276 | refspecs: []string{"7c5ee6478d137417ae602140c615e33aed91887c"}, 277 | opt: RevListCountOptions{ 278 | Path: "resources", 279 | }, 280 | expCount: 1, 281 | }, 282 | } 283 | for _, test := range tests { 284 | t.Run("", func(t *testing.T) { 285 | count, err := testrepo.RevListCount(test.refspecs, test.opt) 286 | if err != nil { 287 | t.Fatal(err) 288 | } 289 | 290 | assert.Equal(t, test.expCount, count) 291 | }) 292 | } 293 | } 294 | 295 | func TestRepository_RevList(t *testing.T) { 296 | t.Run("no refspecs", func(t *testing.T) { 297 | commits, err := testrepo.RevList([]string{}) 298 | assert.Equal(t, errors.New("must have at least one refspec"), err) 299 | assert.Nil(t, commits) 300 | }) 301 | 302 | tests := []struct { 303 | refspecs []string 304 | opt RevListOptions 305 | expCommitIDs []string 306 | }{ 307 | { 308 | refspecs: []string{"45a30ea9afa413e226ca8614179c011d545ca883...978fb7f6388b49b532fbef8b856681cfa6fcaa0a"}, 309 | expCommitIDs: []string{ 310 | "978fb7f6388b49b532fbef8b856681cfa6fcaa0a", 311 | "ef7bebf8bdb1919d947afe46ab4b2fb4278039b3", 312 | "ebbbf773431ba07510251bb03f9525c7bab2b13a", 313 | }, 314 | }, 315 | { 316 | refspecs: []string{"45a30ea9afa413e226ca8614179c011d545ca883...978fb7f6388b49b532fbef8b856681cfa6fcaa0a"}, 317 | opt: RevListOptions{ 318 | Path: "src", 319 | }, 320 | expCommitIDs: []string{ 321 | "ebbbf773431ba07510251bb03f9525c7bab2b13a", 322 | }, 323 | }, 324 | } 325 | for _, test := range tests { 326 | t.Run("", func(t *testing.T) { 327 | commits, err := testrepo.RevList(test.refspecs, test.opt) 328 | if err != nil { 329 | t.Fatal(err) 330 | } 331 | 332 | assert.Equal(t, test.expCommitIDs, commitsToIDs(commits)) 333 | }) 334 | } 335 | } 336 | 337 | func TestRepository_LatestCommitTime(t *testing.T) { 338 | tests := []struct { 339 | opt LatestCommitTimeOptions 340 | expTime time.Time 341 | }{ 342 | { 343 | opt: LatestCommitTimeOptions{ 344 | Branch: "release-1.0", 345 | }, 346 | expTime: time.Unix(1581256638, 0), 347 | }, 348 | } 349 | for _, test := range tests { 350 | t.Run("", func(t *testing.T) { 351 | got, err := testrepo.LatestCommitTime(test.opt) 352 | if err != nil { 353 | t.Fatal(err) 354 | } 355 | 356 | assert.Equal(t, test.expTime.Unix(), got.Unix()) 357 | }) 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /repo_diff.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "bytes" 9 | "fmt" 10 | "io" 11 | "time" 12 | ) 13 | 14 | // DiffOptions contains optional arguments for parsing diff. 15 | // 16 | // Docs: https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---full-index 17 | type DiffOptions struct { 18 | // The commit ID to used for computing diff between a range of commits (base, 19 | // revision]. When not set, only computes diff for a single commit at revision. 20 | Base string 21 | // The timeout duration before giving up for each shell command execution. The 22 | // default timeout duration will be used when not supplied. 23 | // 24 | // Deprecated: Use CommandOptions.Timeout instead. 25 | Timeout time.Duration 26 | // The additional options to be passed to the underlying git. 27 | CommandOptions 28 | } 29 | 30 | // Diff returns a parsed diff object between given commits of the repository. 31 | func (r *Repository) Diff(rev string, maxFiles, maxFileLines, maxLineChars int, opts ...DiffOptions) (*Diff, error) { 32 | var opt DiffOptions 33 | if len(opts) > 0 { 34 | opt = opts[0] 35 | } 36 | 37 | commit, err := r.CatFileCommit(rev, CatFileCommitOptions{Timeout: opt.Timeout}) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | cmd := NewCommand() 43 | if opt.Base == "" { 44 | // First commit of repository 45 | if commit.ParentsCount() == 0 { 46 | cmd = cmd.AddArgs("show"). 47 | AddOptions(opt.CommandOptions). 48 | AddArgs("--full-index", rev) 49 | } else { 50 | c, err := commit.Parent(0) 51 | if err != nil { 52 | return nil, err 53 | } 54 | cmd = cmd.AddArgs("diff"). 55 | AddOptions(opt.CommandOptions). 56 | AddArgs("--full-index", "-M", c.ID.String(), rev) 57 | } 58 | } else { 59 | cmd = cmd.AddArgs("diff"). 60 | AddOptions(opt.CommandOptions). 61 | AddArgs("--full-index", "-M", opt.Base, rev) 62 | } 63 | 64 | stdout, w := io.Pipe() 65 | done := make(chan SteamParseDiffResult) 66 | go StreamParseDiff(stdout, done, maxFiles, maxFileLines, maxLineChars) 67 | 68 | stderr := new(bytes.Buffer) 69 | err = cmd.RunInDirPipelineWithTimeout(opt.Timeout, w, stderr, r.path) 70 | _ = w.Close() // Close writer to exit parsing goroutine 71 | if err != nil { 72 | return nil, concatenateError(err, stderr.String()) 73 | } 74 | 75 | result := <-done 76 | return result.Diff, result.Err 77 | } 78 | 79 | // RawDiffFormat is the format of a raw diff. 80 | type RawDiffFormat string 81 | 82 | const ( 83 | RawDiffNormal RawDiffFormat = "diff" 84 | RawDiffPatch RawDiffFormat = "patch" 85 | ) 86 | 87 | // RawDiffOptions contains optional arguments for dumping a raw diff. 88 | // 89 | // Docs: https://git-scm.com/docs/git-format-patch 90 | type RawDiffOptions struct { 91 | // The timeout duration before giving up for each shell command execution. The 92 | // default timeout duration will be used when not supplied. 93 | Timeout time.Duration 94 | // The additional options to be passed to the underlying git. 95 | CommandOptions 96 | } 97 | 98 | // RawDiff dumps diff of repository in given revision directly to given 99 | // io.Writer. 100 | func (r *Repository) RawDiff(rev string, diffType RawDiffFormat, w io.Writer, opts ...RawDiffOptions) error { 101 | var opt RawDiffOptions 102 | if len(opts) > 0 { 103 | opt = opts[0] 104 | } 105 | 106 | commit, err := r.CatFileCommit(rev, CatFileCommitOptions{Timeout: opt.Timeout}) //nolint 107 | if err != nil { 108 | return err 109 | } 110 | 111 | cmd := NewCommand() 112 | switch diffType { 113 | case RawDiffNormal: 114 | if commit.ParentsCount() == 0 { 115 | cmd = cmd.AddArgs("show"). 116 | AddOptions(opt.CommandOptions). 117 | AddArgs("--full-index", rev) 118 | } else { 119 | c, err := commit.Parent(0) 120 | if err != nil { 121 | return err 122 | } 123 | cmd = cmd.AddArgs("diff"). 124 | AddOptions(opt.CommandOptions). 125 | AddArgs("--full-index", "-M", c.ID.String(), rev) 126 | } 127 | case RawDiffPatch: 128 | if commit.ParentsCount() == 0 { 129 | cmd = cmd.AddArgs("format-patch"). 130 | AddOptions(opt.CommandOptions). 131 | AddArgs("--full-index", "--no-signoff", "--no-signature", "--stdout", "--root", rev) 132 | } else { 133 | c, err := commit.Parent(0) 134 | if err != nil { 135 | return err 136 | } 137 | cmd = cmd.AddArgs("format-patch"). 138 | AddOptions(opt.CommandOptions). 139 | AddArgs("--full-index", "--no-signoff", "--no-signature", "--stdout", rev+"..."+c.ID.String()) 140 | } 141 | default: 142 | return fmt.Errorf("invalid diffType: %s", diffType) 143 | } 144 | 145 | stderr := new(bytes.Buffer) 146 | if err = cmd.RunInDirPipelineWithTimeout(opt.Timeout, w, stderr, r.path); err != nil { 147 | return concatenateError(err, stderr.String()) 148 | } 149 | return nil 150 | } 151 | 152 | // DiffBinaryOptions contains optional arguments for producing binary patch. 153 | type DiffBinaryOptions struct { 154 | // The timeout duration before giving up for each shell command execution. The 155 | // default timeout duration will be used when not supplied. 156 | Timeout time.Duration 157 | // The additional options to be passed to the underlying git. 158 | CommandOptions 159 | } 160 | 161 | // DiffBinary returns binary patch between base and head revisions that could be 162 | // used for git-apply. 163 | func (r *Repository) DiffBinary(base, head string, opts ...DiffBinaryOptions) ([]byte, error) { 164 | var opt DiffBinaryOptions 165 | if len(opts) > 0 { 166 | opt = opts[0] 167 | } 168 | 169 | return NewCommand("diff"). 170 | AddOptions(opt.CommandOptions). 171 | AddArgs("--full-index", "--binary", base, head). 172 | RunInDirWithTimeout(opt.Timeout, r.path) 173 | } 174 | -------------------------------------------------------------------------------- /repo_grep.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "fmt" 9 | "strconv" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | // GrepOptions contains optional arguments for grep search over repository files. 15 | // 16 | // Docs: https://git-scm.com/docs/git-grep 17 | type GrepOptions struct { 18 | // The tree to run the search. Defaults to "HEAD". 19 | Tree string 20 | // Limits the search to files in the specified pathspec. 21 | Pathspec string 22 | // Whether to do case insensitive search. 23 | IgnoreCase bool 24 | // Whether to match the pattern only at word boundaries. 25 | WordRegexp bool 26 | // Whether use extended regular expressions. 27 | ExtendedRegexp bool 28 | // The timeout duration before giving up for each shell command execution. The 29 | // default timeout duration will be used when not supplied. 30 | // 31 | // Deprecated: Use CommandOptions.Timeout instead. 32 | Timeout time.Duration 33 | // The additional options to be passed to the underlying git. 34 | CommandOptions 35 | } 36 | 37 | // GrepResult represents a single result from a grep search. 38 | type GrepResult struct { 39 | // The tree of the file that matched, e.g. "HEAD". 40 | Tree string 41 | // The path of the file that matched. 42 | Path string 43 | // The line number of the match. 44 | Line int 45 | // The 1-indexed column number of the match. 46 | Column int 47 | // The text of the line that matched. 48 | Text string 49 | } 50 | 51 | func parseGrepLine(line string) (*GrepResult, error) { 52 | r := &GrepResult{} 53 | sp := strings.SplitN(line, ":", 5) 54 | var n int 55 | switch len(sp) { 56 | case 4: 57 | // HEAD 58 | r.Tree = "HEAD" 59 | case 5: 60 | // Tree included 61 | r.Tree = sp[0] 62 | n++ 63 | default: 64 | return nil, fmt.Errorf("invalid grep line: %s", line) 65 | } 66 | r.Path = sp[n] 67 | n++ 68 | r.Line, _ = strconv.Atoi(sp[n]) 69 | n++ 70 | r.Column, _ = strconv.Atoi(sp[n]) 71 | n++ 72 | r.Text = sp[n] 73 | return r, nil 74 | } 75 | 76 | // Grep returns the results of a grep search in the repository. 77 | func (r *Repository) Grep(pattern string, opts ...GrepOptions) []*GrepResult { 78 | var opt GrepOptions 79 | if len(opts) > 0 { 80 | opt = opts[0] 81 | } 82 | if opt.Tree == "" { 83 | opt.Tree = "HEAD" 84 | } 85 | 86 | cmd := NewCommand("grep"). 87 | AddOptions(opt.CommandOptions). 88 | // Display full-name, line number and column number 89 | AddArgs("--full-name", "--line-number", "--column") 90 | if opt.IgnoreCase { 91 | cmd.AddArgs("--ignore-case") 92 | } 93 | if opt.WordRegexp { 94 | cmd.AddArgs("--word-regexp") 95 | } 96 | if opt.ExtendedRegexp { 97 | cmd.AddArgs("--extended-regexp") 98 | } 99 | cmd.AddArgs(pattern, opt.Tree) 100 | if opt.Pathspec != "" { 101 | cmd.AddArgs("--", opt.Pathspec) 102 | } 103 | 104 | stdout, err := cmd.RunInDirWithTimeout(opt.Timeout, r.path) 105 | if err != nil { 106 | return nil 107 | } 108 | 109 | var results []*GrepResult 110 | // Normalize line endings 111 | lines := strings.Split(strings.ReplaceAll(string(stdout), "\r", ""), "\n") 112 | for _, line := range lines { 113 | if len(line) == 0 { 114 | continue 115 | } 116 | r, err := parseGrepLine(line) 117 | if err == nil { 118 | results = append(results, r) 119 | } 120 | } 121 | return results 122 | } 123 | -------------------------------------------------------------------------------- /repo_grep_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "runtime" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestRepository_Grep_Simple(t *testing.T) { 15 | want := []*GrepResult{ 16 | { 17 | Tree: "HEAD", 18 | Path: "src/Main.groovy", 19 | Line: 7, 20 | Column: 5, 21 | Text: "int programmingPoints = 10", 22 | }, { 23 | Tree: "HEAD", 24 | Path: "src/Main.groovy", 25 | Line: 10, 26 | Column: 33, 27 | Text: `println "${name} has at least ${programmingPoints} programming points."`, 28 | }, { 29 | Tree: "HEAD", 30 | Path: "src/Main.groovy", 31 | Line: 11, 32 | Column: 12, 33 | Text: `println "${programmingPoints} squared is ${square(programmingPoints)}"`, 34 | }, { 35 | Tree: "HEAD", 36 | Path: "src/Main.groovy", 37 | Line: 12, 38 | Column: 12, 39 | Text: `println "${programmingPoints} divided by 2 bonus points is ${divide(programmingPoints, 2)}"`, 40 | }, { 41 | Tree: "HEAD", 42 | Path: "src/Main.groovy", 43 | Line: 13, 44 | Column: 12, 45 | Text: `println "${programmingPoints} minus 7 bonus points is ${subtract(programmingPoints, 7)}"`, 46 | }, { 47 | Tree: "HEAD", 48 | Path: "src/Main.groovy", 49 | Line: 14, 50 | Column: 12, 51 | Text: `println "${programmingPoints} plus 3 bonus points is ${sum(programmingPoints, 3)}"`, 52 | }, 53 | } 54 | got := testrepo.Grep("programmingPoints") 55 | assert.Equal(t, want, got) 56 | } 57 | 58 | func TestRepository_Grep_IgnoreCase(t *testing.T) { 59 | want := []*GrepResult{ 60 | { 61 | Tree: "HEAD", 62 | Path: "README.txt", 63 | Line: 9, 64 | Column: 36, 65 | Text: "* git@github.com:matthewmccullough/hellogitworld.git", 66 | }, { 67 | Tree: "HEAD", 68 | Path: "README.txt", 69 | Line: 10, 70 | Column: 38, 71 | Text: "* git://github.com/matthewmccullough/hellogitworld.git", 72 | }, { 73 | Tree: "HEAD", 74 | Path: "README.txt", 75 | Line: 11, 76 | Column: 58, 77 | Text: "* https://matthewmccullough@github.com/matthewmccullough/hellogitworld.git", 78 | }, { 79 | Tree: "HEAD", 80 | Path: "src/Main.groovy", 81 | Line: 9, 82 | Column: 10, 83 | Text: `println "Hello ${name}"`, 84 | }, { 85 | Tree: "HEAD", 86 | Path: "src/main/java/com/github/App.java", 87 | Line: 4, 88 | Column: 4, 89 | Text: " * Hello again", 90 | }, { 91 | Tree: "HEAD", 92 | Path: "src/main/java/com/github/App.java", 93 | Line: 5, 94 | Column: 4, 95 | Text: " * Hello world!", 96 | }, { 97 | Tree: "HEAD", 98 | Path: "src/main/java/com/github/App.java", 99 | Line: 6, 100 | Column: 4, 101 | Text: " * Hello", 102 | }, { 103 | Tree: "HEAD", 104 | Path: "src/main/java/com/github/App.java", 105 | Line: 13, 106 | Column: 30, 107 | Text: ` System.out.println( "Hello World!" );`, 108 | }, 109 | } 110 | got := testrepo.Grep("Hello", GrepOptions{IgnoreCase: true}) 111 | assert.Equal(t, want, got) 112 | } 113 | 114 | func TestRepository_Grep_ExtendedRegexp(t *testing.T) { 115 | if runtime.GOOS == "darwin" { 116 | t.Skip("Skipping testing on macOS") 117 | return 118 | } 119 | want := []*GrepResult{ 120 | { 121 | Tree: "HEAD", 122 | Path: "src/main/java/com/github/App.java", 123 | Line: 13, 124 | Column: 30, 125 | Text: ` System.out.println( "Hello World!" );`, 126 | }, 127 | } 128 | got := testrepo.Grep(`Hello\sW\w+`, GrepOptions{ExtendedRegexp: true}) 129 | assert.Equal(t, want, got) 130 | } 131 | 132 | func TestRepository_Grep_WordRegexp(t *testing.T) { 133 | want := []*GrepResult{ 134 | { 135 | Tree: "HEAD", 136 | Path: "src/main/java/com/github/App.java", 137 | Line: 5, 138 | Column: 10, 139 | Text: ` * Hello world!`, 140 | }, 141 | } 142 | got := testrepo.Grep("world", GrepOptions{WordRegexp: true}) 143 | assert.Equal(t, want, got) 144 | } 145 | -------------------------------------------------------------------------------- /repo_hook.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "io/ioutil" 9 | "os" 10 | "path/filepath" 11 | ) 12 | 13 | // DefaultHooksDir is the default directory for Git hooks. 14 | const DefaultHooksDir = "hooks" 15 | 16 | // NewHook creates and returns a new hook with given name. Update method must be 17 | // called to actually save the hook to disk. 18 | func (r *Repository) NewHook(dir string, name HookName) *Hook { 19 | return &Hook{ 20 | name: name, 21 | path: filepath.Join(r.path, dir, string(name)), 22 | } 23 | } 24 | 25 | // Hook returns a Git hook by given name in the repository. Giving empty 26 | // directory will use the default directory. It returns an os.ErrNotExist if 27 | // both active and sample hook do not exist. 28 | func (r *Repository) Hook(dir string, name HookName) (*Hook, error) { 29 | if dir == "" { 30 | dir = DefaultHooksDir 31 | } 32 | // 1. Check if there is an active hook. 33 | fpath := filepath.Join(r.path, dir, string(name)) 34 | if isFile(fpath) { 35 | p, err := ioutil.ReadFile(fpath) 36 | if err != nil { 37 | return nil, err 38 | } 39 | return &Hook{ 40 | name: name, 41 | path: fpath, 42 | content: string(p), 43 | }, nil 44 | } 45 | 46 | // 2. Check if sample content exists. 47 | sample := ServerSideHookSamples[name] 48 | if sample != "" { 49 | return &Hook{ 50 | name: name, 51 | path: fpath, 52 | isSample: true, 53 | content: sample, 54 | }, nil 55 | } 56 | 57 | return nil, os.ErrNotExist 58 | } 59 | 60 | // Hooks returns a list of Git hooks found in the repository. Giving empty 61 | // directory will use the default directory. It may return an empty slice when 62 | // no hooks found. 63 | func (r *Repository) Hooks(dir string) ([]*Hook, error) { 64 | hooks := make([]*Hook, 0, len(ServerSideHooks)) 65 | for _, name := range ServerSideHooks { 66 | h, err := r.Hook(dir, name) 67 | if err != nil { 68 | if err == os.ErrNotExist { 69 | continue 70 | } 71 | return nil, err 72 | } 73 | hooks = append(hooks, h) 74 | } 75 | return hooks, nil 76 | } 77 | -------------------------------------------------------------------------------- /repo_hook_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "os" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestRepository_Hooks(t *testing.T) { 15 | t.Run("invalid hook", func(t *testing.T) { 16 | h, err := testrepo.Hook("", "bad_hook") 17 | assert.Equal(t, os.ErrNotExist, err) 18 | assert.Nil(t, h) 19 | }) 20 | 21 | // Save "post-receive" hook with some content 22 | postReceiveHook := testrepo.NewHook(DefaultHooksDir, HookPostReceive) 23 | defer func() { 24 | _ = os.Remove(postReceiveHook.Path()) 25 | }() 26 | 27 | err := postReceiveHook.Update("echo $1 $2 $3") 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | 32 | hooks, err := testrepo.Hooks("") 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | assert.Equal(t, 3, len(hooks)) 37 | 38 | for i := range hooks { 39 | assert.NotEmpty(t, hooks[i].Content()) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /repo_pull.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // MergeBaseOptions contains optional arguments for getting merge base. 13 | // 14 | // Docs: https://git-scm.com/docs/git-merge-base 15 | type MergeBaseOptions struct { 16 | // The timeout duration before giving up for each shell command execution. The 17 | // default timeout duration will be used when not supplied. 18 | // 19 | // Deprecated: Use CommandOptions.Timeout instead. 20 | Timeout time.Duration 21 | // The additional options to be passed to the underlying git. 22 | CommandOptions 23 | } 24 | 25 | // MergeBase returns merge base between base and head revisions of the 26 | // repository in given path. 27 | func MergeBase(repoPath, base, head string, opts ...MergeBaseOptions) (string, error) { 28 | var opt MergeBaseOptions 29 | if len(opts) > 0 { 30 | opt = opts[0] 31 | } 32 | 33 | stdout, err := NewCommand("merge-base"). 34 | AddOptions(opt.CommandOptions). 35 | AddArgs(base, head). 36 | RunInDirWithTimeout(opt.Timeout, repoPath) 37 | if err != nil { 38 | if strings.Contains(err.Error(), "exit status 1") { 39 | return "", ErrNoMergeBase 40 | } 41 | return "", err 42 | } 43 | return strings.TrimSpace(string(stdout)), nil 44 | } 45 | 46 | // Deprecated: Use MergeBase instead. 47 | func RepoMergeBase(repoPath, base, head string, opts ...MergeBaseOptions) (string, error) { 48 | return MergeBase(repoPath, base, head, opts...) 49 | } 50 | 51 | // MergeBase returns merge base between base and head revisions of the 52 | // repository. 53 | func (r *Repository) MergeBase(base, head string, opts ...MergeBaseOptions) (string, error) { 54 | return MergeBase(r.path, base, head, opts...) 55 | } 56 | -------------------------------------------------------------------------------- /repo_pull_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestRepository_MergeBase(t *testing.T) { 14 | t.Run("no merge base", func(t *testing.T) { 15 | mb, err := testrepo.MergeBase("0eedd79eba4394bbef888c804e899731644367fe", "bad_revision") 16 | assert.Equal(t, ErrNoMergeBase, err) 17 | assert.Empty(t, mb) 18 | }) 19 | 20 | tests := []struct { 21 | base string 22 | head string 23 | opt MergeBaseOptions 24 | expMergeBase string 25 | }{ 26 | { 27 | base: "4e59b72440188e7c2578299fc28ea425fbe9aece", 28 | head: "0eedd79eba4394bbef888c804e899731644367fe", 29 | expMergeBase: "4e59b72440188e7c2578299fc28ea425fbe9aece", 30 | }, 31 | { 32 | base: "master", 33 | head: "release-1.0", 34 | expMergeBase: "0eedd79eba4394bbef888c804e899731644367fe", 35 | }, 36 | } 37 | for _, test := range tests { 38 | t.Run("", func(t *testing.T) { 39 | mb, err := testrepo.MergeBase(test.base, test.head, test.opt) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | assert.Equal(t, test.expMergeBase, mb) 45 | }) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /repo_reference.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "errors" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | const ( 14 | RefsHeads = "refs/heads/" 15 | RefsTags = "refs/tags/" 16 | ) 17 | 18 | // RefShortName returns short name of heads or tags. Other references will 19 | // return original string. 20 | func RefShortName(ref string) string { 21 | if strings.HasPrefix(ref, RefsHeads) { 22 | return ref[len(RefsHeads):] 23 | } else if strings.HasPrefix(ref, RefsTags) { 24 | return ref[len(RefsTags):] 25 | } 26 | 27 | return ref 28 | } 29 | 30 | // Reference contains information of a Git reference. 31 | type Reference struct { 32 | ID string 33 | Refspec string 34 | } 35 | 36 | // ShowRefVerifyOptions contains optional arguments for verifying a reference. 37 | // 38 | // Docs: https://git-scm.com/docs/git-show-ref#Documentation/git-show-ref.txt---verify 39 | type ShowRefVerifyOptions struct { 40 | // The timeout duration before giving up for each shell command execution. The 41 | // default timeout duration will be used when not supplied. 42 | // 43 | // Deprecated: Use CommandOptions.Timeout instead. 44 | Timeout time.Duration 45 | // The additional options to be passed to the underlying git. 46 | CommandOptions 47 | } 48 | 49 | var ErrReferenceNotExist = errors.New("reference does not exist") 50 | 51 | // ShowRefVerify returns the commit ID of given reference if it exists in the 52 | // repository in given path. 53 | func ShowRefVerify(repoPath, ref string, opts ...ShowRefVerifyOptions) (string, error) { 54 | var opt ShowRefVerifyOptions 55 | if len(opts) > 0 { 56 | opt = opts[0] 57 | } 58 | 59 | cmd := NewCommand("show-ref", "--verify", ref).AddOptions(opt.CommandOptions) 60 | stdout, err := cmd.RunInDirWithTimeout(opt.Timeout, repoPath) 61 | if err != nil { 62 | if strings.Contains(err.Error(), "not a valid ref") { 63 | return "", ErrReferenceNotExist 64 | } 65 | return "", err 66 | } 67 | return strings.Split(string(stdout), " ")[0], nil 68 | } 69 | 70 | // Deprecated: Use ShowRefVerify instead. 71 | func RepoShowRefVerify(repoPath, ref string, opts ...ShowRefVerifyOptions) (string, error) { 72 | return ShowRefVerify(repoPath, ref, opts...) 73 | } 74 | 75 | // ShowRefVerify returns the commit ID of given reference (e.g. 76 | // "refs/heads/master") if it exists in the repository. 77 | func (r *Repository) ShowRefVerify(ref string, opts ...ShowRefVerifyOptions) (string, error) { 78 | return ShowRefVerify(r.path, ref, opts...) 79 | } 80 | 81 | // BranchCommitID returns the commit ID of given branch if it exists in the 82 | // repository. The branch must be given in short name e.g. "master". 83 | func (r *Repository) BranchCommitID(branch string, opts ...ShowRefVerifyOptions) (string, error) { 84 | return r.ShowRefVerify(RefsHeads+branch, opts...) 85 | } 86 | 87 | // TagCommitID returns the commit ID of given tag if it exists in the 88 | // repository. The tag must be given in short name e.g. "v1.0.0". 89 | func (r *Repository) TagCommitID(tag string, opts ...ShowRefVerifyOptions) (string, error) { 90 | return r.ShowRefVerify(RefsTags+tag, opts...) 91 | } 92 | 93 | // RepoHasReference returns true if given reference exists in the repository in 94 | // given path. The reference must be given in full refspec, e.g. 95 | // "refs/heads/master". 96 | func RepoHasReference(repoPath, ref string, opts ...ShowRefVerifyOptions) bool { 97 | _, err := ShowRefVerify(repoPath, ref, opts...) 98 | return err == nil 99 | } 100 | 101 | // RepoHasBranch returns true if given branch exists in the repository in given 102 | // path. The branch must be given in short name e.g. "master". 103 | func RepoHasBranch(repoPath, branch string, opts ...ShowRefVerifyOptions) bool { 104 | return RepoHasReference(repoPath, RefsHeads+branch, opts...) 105 | } 106 | 107 | // HasTag returns true if given tag exists in the repository in given path. The 108 | // tag must be given in short name e.g. "v1.0.0". 109 | func HasTag(repoPath, tag string, opts ...ShowRefVerifyOptions) bool { 110 | return RepoHasReference(repoPath, RefsTags+tag, opts...) 111 | } 112 | 113 | // Deprecated: Use HasTag instead. 114 | func RepoHasTag(repoPath, tag string, opts ...ShowRefVerifyOptions) bool { 115 | return HasTag(repoPath, tag, opts...) 116 | } 117 | 118 | // HasReference returns true if given reference exists in the repository. The 119 | // reference must be given in full refspec, e.g. "refs/heads/master". 120 | func (r *Repository) HasReference(ref string, opts ...ShowRefVerifyOptions) bool { 121 | return RepoHasReference(r.path, ref, opts...) 122 | } 123 | 124 | // HasBranch returns true if given branch exists in the repository. The branch 125 | // must be given in short name e.g. "master". 126 | func (r *Repository) HasBranch(branch string, opts ...ShowRefVerifyOptions) bool { 127 | return RepoHasBranch(r.path, branch, opts...) 128 | } 129 | 130 | // HasTag returns true if given tag exists in the repository. The tag must be 131 | // given in short name e.g. "v1.0.0". 132 | func (r *Repository) HasTag(tag string, opts ...ShowRefVerifyOptions) bool { 133 | return HasTag(r.path, tag, opts...) 134 | } 135 | 136 | // SymbolicRefOptions contains optional arguments for get and set symbolic ref. 137 | type SymbolicRefOptions struct { 138 | // The name of the symbolic ref. When not set, default ref "HEAD" is used. 139 | Name string 140 | // The name of the reference, e.g. "refs/heads/master". When set, it will be 141 | // used to update the symbolic ref. 142 | Ref string 143 | // The timeout duration before giving up for each shell command execution. The 144 | // default timeout duration will be used when not supplied. 145 | // 146 | // Deprecated: Use CommandOptions.Timeout instead. 147 | Timeout time.Duration 148 | // The additional options to be passed to the underlying git. 149 | CommandOptions 150 | } 151 | 152 | // SymbolicRef returns the reference name (e.g. "refs/heads/master") pointed by 153 | // the symbolic ref in the repository in given path. It returns an empty string 154 | // and nil error when doing set operation. 155 | func SymbolicRef(repoPath string, opts ...SymbolicRefOptions) (string, error) { 156 | var opt SymbolicRefOptions 157 | if len(opts) > 0 { 158 | opt = opts[0] 159 | } 160 | 161 | cmd := NewCommand("symbolic-ref").AddOptions(opt.CommandOptions) 162 | if opt.Name == "" { 163 | opt.Name = "HEAD" 164 | } 165 | cmd.AddArgs(opt.Name) 166 | if opt.Ref != "" { 167 | cmd.AddArgs(opt.Ref) 168 | } 169 | 170 | stdout, err := cmd.RunInDirWithTimeout(opt.Timeout, repoPath) 171 | if err != nil { 172 | return "", err 173 | } 174 | return strings.TrimSpace(string(stdout)), nil 175 | } 176 | 177 | // SymbolicRef returns the reference name (e.g. "refs/heads/master") pointed by 178 | // the symbolic ref. It returns an empty string and nil error when doing set 179 | // operation. 180 | func (r *Repository) SymbolicRef(opts ...SymbolicRefOptions) (string, error) { 181 | return SymbolicRef(r.path, opts...) 182 | } 183 | 184 | // ShowRefOptions contains optional arguments for listing references. 185 | // 186 | // Docs: https://git-scm.com/docs/git-show-ref 187 | type ShowRefOptions struct { 188 | // Indicates whether to include heads. 189 | Heads bool 190 | // Indicates whether to include tags. 191 | Tags bool 192 | // The list of patterns to filter results. 193 | Patterns []string 194 | // The timeout duration before giving up for each shell command execution. The 195 | // default timeout duration will be used when not supplied. 196 | // 197 | // Deprecated: Use CommandOptions.Timeout instead. 198 | Timeout time.Duration 199 | // The additional options to be passed to the underlying git. 200 | CommandOptions 201 | } 202 | 203 | // ShowRef returns a list of references in the repository. 204 | func (r *Repository) ShowRef(opts ...ShowRefOptions) ([]*Reference, error) { 205 | var opt ShowRefOptions 206 | if len(opts) > 0 { 207 | opt = opts[0] 208 | } 209 | 210 | cmd := NewCommand("show-ref").AddOptions(opt.CommandOptions) 211 | if opt.Heads { 212 | cmd.AddArgs("--heads") 213 | } 214 | if opt.Tags { 215 | cmd.AddArgs("--tags") 216 | } 217 | cmd.AddArgs("--") 218 | if len(opt.Patterns) > 0 { 219 | cmd.AddArgs(opt.Patterns...) 220 | } 221 | 222 | stdout, err := cmd.RunInDirWithTimeout(opt.Timeout, r.path) 223 | if err != nil { 224 | return nil, err 225 | } 226 | 227 | lines := strings.Split(string(stdout), "\n") 228 | refs := make([]*Reference, 0, len(lines)) 229 | for i := range lines { 230 | fields := strings.Fields(lines[i]) 231 | if len(fields) != 2 { 232 | continue 233 | } 234 | refs = append(refs, &Reference{ 235 | ID: fields[0], 236 | Refspec: fields[1], 237 | }) 238 | } 239 | return refs, nil 240 | } 241 | 242 | // Branches returns a list of all branches in the repository. 243 | func (r *Repository) Branches() ([]string, error) { 244 | heads, err := r.ShowRef(ShowRefOptions{Heads: true}) 245 | if err != nil { 246 | return nil, err 247 | } 248 | 249 | branches := make([]string, len(heads)) 250 | for i := range heads { 251 | branches[i] = strings.TrimPrefix(heads[i].Refspec, RefsHeads) 252 | } 253 | return branches, nil 254 | } 255 | 256 | // DeleteBranchOptions contains optional arguments for deleting a branch. 257 | // 258 | // Docs: https://git-scm.com/docs/git-branch 259 | type DeleteBranchOptions struct { 260 | // Indicates whether to force delete the branch. 261 | Force bool 262 | // The timeout duration before giving up for each shell command execution. The 263 | // default timeout duration will be used when not supplied. 264 | // 265 | // Deprecated: Use CommandOptions.Timeout instead. 266 | Timeout time.Duration 267 | // The additional options to be passed to the underlying git. 268 | CommandOptions 269 | } 270 | 271 | // DeleteBranch deletes the branch from the repository in given path. 272 | func DeleteBranch(repoPath, name string, opts ...DeleteBranchOptions) error { 273 | var opt DeleteBranchOptions 274 | if len(opts) > 0 { 275 | opt = opts[0] 276 | } 277 | 278 | cmd := NewCommand("branch").AddOptions(opt.CommandOptions) 279 | if opt.Force { 280 | cmd.AddArgs("-D") 281 | } else { 282 | cmd.AddArgs("-d") 283 | } 284 | _, err := cmd.AddArgs(name).RunInDirWithTimeout(opt.Timeout, repoPath) 285 | return err 286 | } 287 | 288 | // Deprecated: Use DeleteBranch instead. 289 | func RepoDeleteBranch(repoPath, name string, opts ...DeleteBranchOptions) error { 290 | return DeleteBranch(repoPath, name, opts...) 291 | } 292 | 293 | // DeleteBranch deletes the branch from the repository. 294 | func (r *Repository) DeleteBranch(name string, opts ...DeleteBranchOptions) error { 295 | return DeleteBranch(r.path, name, opts...) 296 | } 297 | -------------------------------------------------------------------------------- /repo_reference_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "strconv" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestRefShortName(t *testing.T) { 16 | tests := []struct { 17 | ref string 18 | expVal string 19 | }{ 20 | { 21 | ref: "refs/heads/master", 22 | expVal: "master", 23 | }, 24 | { 25 | ref: "refs/tags/v1.0.0", 26 | expVal: "v1.0.0", 27 | }, 28 | { 29 | ref: "refs/pull/98", 30 | expVal: "refs/pull/98", 31 | }, 32 | } 33 | for _, test := range tests { 34 | t.Run("", func(t *testing.T) { 35 | assert.Equal(t, test.expVal, RefShortName(test.ref)) 36 | }) 37 | } 38 | } 39 | 40 | func TestRepository_ShowRefVerify(t *testing.T) { 41 | t.Run("reference does not exsit", func(t *testing.T) { 42 | rev, err := testrepo.ShowRefVerify("bad_reference") 43 | assert.NotNil(t, err) 44 | assert.Empty(t, rev) 45 | }) 46 | 47 | rev, err := testrepo.ShowRefVerify("refs/heads/release-1.0") 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | 52 | assert.Equal(t, "0eedd79eba4394bbef888c804e899731644367fe", rev) 53 | } 54 | 55 | func TestRepository_BranchCommitID(t *testing.T) { 56 | t.Run("branch does not exsit", func(t *testing.T) { 57 | rev, err := testrepo.BranchCommitID("bad_branch") 58 | assert.NotNil(t, err) 59 | assert.Empty(t, rev) 60 | }) 61 | 62 | rev, err := testrepo.BranchCommitID("release-1.0") 63 | if err != nil { 64 | t.Fatal(err) 65 | } 66 | 67 | assert.Equal(t, "0eedd79eba4394bbef888c804e899731644367fe", rev) 68 | } 69 | 70 | func TestRepository_TagCommitID(t *testing.T) { 71 | t.Run("tag does not exsit", func(t *testing.T) { 72 | rev, err := testrepo.TagCommitID("bad_tag") 73 | assert.NotNil(t, err) 74 | assert.Empty(t, rev) 75 | }) 76 | 77 | rev, err := testrepo.TagCommitID("v1.0.0") 78 | if err != nil { 79 | t.Fatal(err) 80 | } 81 | 82 | assert.Equal(t, "0eedd79eba4394bbef888c804e899731644367fe", rev) 83 | } 84 | 85 | func TestRepository_HasReference(t *testing.T) { 86 | tests := []struct { 87 | ref string 88 | opt ShowRefVerifyOptions 89 | expVal bool 90 | }{ 91 | { 92 | ref: RefsHeads + "master", 93 | expVal: true, 94 | }, 95 | { 96 | ref: RefsTags + "v1.0.0", 97 | expVal: true, 98 | }, 99 | { 100 | ref: "master", 101 | expVal: false, 102 | }, 103 | } 104 | for _, test := range tests { 105 | t.Run("", func(t *testing.T) { 106 | assert.Equal(t, test.expVal, testrepo.HasReference(test.ref, test.opt)) 107 | }) 108 | } 109 | } 110 | 111 | func TestRepository_HasBranch(t *testing.T) { 112 | tests := []struct { 113 | ref string 114 | opt ShowRefVerifyOptions 115 | expVal bool 116 | }{ 117 | { 118 | ref: "master", 119 | expVal: true, 120 | }, 121 | { 122 | ref: RefsHeads + "master", 123 | expVal: false, 124 | }, 125 | } 126 | for _, test := range tests { 127 | t.Run("", func(t *testing.T) { 128 | assert.Equal(t, test.expVal, testrepo.HasBranch(test.ref, test.opt)) 129 | }) 130 | } 131 | } 132 | 133 | func TestRepository_HasTag(t *testing.T) { 134 | tests := []struct { 135 | ref string 136 | opt ShowRefVerifyOptions 137 | expVal bool 138 | }{ 139 | { 140 | ref: "v1.0.0", 141 | expVal: true, 142 | }, 143 | { 144 | ref: RefsTags + "v1.0.0", 145 | expVal: false, 146 | }, 147 | } 148 | for _, test := range tests { 149 | t.Run("", func(t *testing.T) { 150 | assert.Equal(t, test.expVal, testrepo.HasTag(test.ref, test.opt)) 151 | }) 152 | } 153 | } 154 | 155 | func TestRepository_SymbolicRef(t *testing.T) { 156 | r, cleanup, err := setupTempRepo() 157 | if err != nil { 158 | t.Fatal(err) 159 | } 160 | defer cleanup() 161 | 162 | // Get HEAD 163 | ref, err := r.SymbolicRef() 164 | if err != nil { 165 | t.Fatal(err) 166 | } 167 | assert.Equal(t, RefsHeads+"master", ref) 168 | 169 | // Set a symbolic reference 170 | _, err = r.SymbolicRef(SymbolicRefOptions{ 171 | Name: "TEST_REF", 172 | Ref: RefsHeads + "develop", 173 | }) 174 | if err != nil { 175 | t.Fatal(err) 176 | } 177 | 178 | // Get the symbolic reference we just set 179 | ref, err = r.SymbolicRef(SymbolicRefOptions{ 180 | Name: "TEST_REF", 181 | }) 182 | if err != nil { 183 | t.Fatal(err) 184 | } 185 | assert.Equal(t, RefsHeads+"develop", ref) 186 | } 187 | 188 | func TestRepository_ShowRef(t *testing.T) { 189 | tests := []struct { 190 | opt ShowRefOptions 191 | expRefs []*Reference 192 | }{ 193 | { 194 | opt: ShowRefOptions{ 195 | Heads: true, 196 | Patterns: []string{"release-1.0"}, 197 | }, 198 | expRefs: []*Reference{ 199 | { 200 | ID: "0eedd79eba4394bbef888c804e899731644367fe", 201 | Refspec: "refs/heads/release-1.0", 202 | }, 203 | }, 204 | }, { 205 | opt: ShowRefOptions{ 206 | Tags: true, 207 | Patterns: []string{"v1.0.0"}, 208 | }, 209 | expRefs: []*Reference{ 210 | { 211 | ID: "0eedd79eba4394bbef888c804e899731644367fe", 212 | Refspec: "refs/tags/v1.0.0", 213 | }, 214 | }, 215 | }, 216 | } 217 | for _, test := range tests { 218 | t.Run("", func(t *testing.T) { 219 | refs, err := testrepo.ShowRef(test.opt) 220 | if err != nil { 221 | t.Fatal(err) 222 | } 223 | 224 | assert.Equal(t, test.expRefs, refs) 225 | }) 226 | } 227 | } 228 | 229 | func TestRepository_Branches(t *testing.T) { 230 | expBranches := map[string]bool{ 231 | "master": true, 232 | "develop": true, 233 | "release-1.0": true, 234 | } 235 | branches, err := testrepo.Branches() 236 | if err != nil { 237 | t.Fatal(err) 238 | } 239 | for _, b := range branches { 240 | delete(expBranches, b) 241 | } 242 | 243 | if len(expBranches) > 0 { 244 | t.Fatalf("expect to be empty but got %v", expBranches) 245 | } 246 | } 247 | 248 | func TestRepository_DeleteBranch(t *testing.T) { 249 | r, cleanup, err := setupTempRepo() 250 | if err != nil { 251 | t.Fatal(err) 252 | } 253 | defer cleanup() 254 | 255 | tests := []struct { 256 | opt DeleteBranchOptions 257 | }{ 258 | { 259 | opt: DeleteBranchOptions{ 260 | Force: false, 261 | }, 262 | }, 263 | { 264 | opt: DeleteBranchOptions{ 265 | Force: true, 266 | }, 267 | }, 268 | } 269 | for _, test := range tests { 270 | t.Run("", func(t *testing.T) { 271 | branch := strconv.Itoa(int(time.Now().UnixNano())) 272 | err := r.Checkout(branch, CheckoutOptions{ 273 | BaseBranch: "master", 274 | }) 275 | if err != nil { 276 | t.Fatal(err) 277 | } 278 | 279 | assert.True(t, r.HasReference(RefsHeads+branch)) 280 | 281 | err = r.Checkout("master") 282 | if err != nil { 283 | t.Fatal(err) 284 | } 285 | 286 | err = r.DeleteBranch(branch, test.opt) 287 | if err != nil { 288 | t.Fatal(err) 289 | } 290 | 291 | assert.False(t, r.HasReference(RefsHeads+branch)) 292 | }) 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /repo_remote.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "bytes" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | // LsRemoteOptions contains arguments for listing references in a remote 14 | // repository. 15 | // 16 | // Docs: https://git-scm.com/docs/git-ls-remote 17 | type LsRemoteOptions struct { 18 | // Indicates whether include heads. 19 | Heads bool 20 | // Indicates whether include tags. 21 | Tags bool 22 | // Indicates whether to not show peeled tags or pseudo refs. 23 | Refs bool 24 | // The list of patterns to filter results. 25 | Patterns []string 26 | // The timeout duration before giving up for each shell command execution. The 27 | // default timeout duration will be used when not supplied. 28 | // 29 | // Deprecated: Use CommandOptions.Timeout instead. 30 | Timeout time.Duration 31 | // The additional options to be passed to the underlying git. 32 | CommandOptions 33 | } 34 | 35 | // LsRemote returns a list references in the remote repository. 36 | func LsRemote(url string, opts ...LsRemoteOptions) ([]*Reference, error) { 37 | var opt LsRemoteOptions 38 | if len(opts) > 0 { 39 | opt = opts[0] 40 | } 41 | 42 | cmd := NewCommand("ls-remote", "--quiet").AddOptions(opt.CommandOptions) 43 | if opt.Heads { 44 | cmd.AddArgs("--heads") 45 | } 46 | if opt.Tags { 47 | cmd.AddArgs("--tags") 48 | } 49 | if opt.Refs { 50 | cmd.AddArgs("--refs") 51 | } 52 | cmd.AddArgs(url) 53 | if len(opt.Patterns) > 0 { 54 | cmd.AddArgs(opt.Patterns...) 55 | } 56 | 57 | stdout, err := cmd.RunWithTimeout(opt.Timeout) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | lines := bytes.Split(stdout, []byte("\n")) 63 | refs := make([]*Reference, 0, len(lines)) 64 | for i := range lines { 65 | fields := bytes.Fields(lines[i]) 66 | if len(fields) < 2 { 67 | continue 68 | } 69 | 70 | refs = append(refs, &Reference{ 71 | ID: string(fields[0]), 72 | Refspec: string(fields[1]), 73 | }) 74 | } 75 | return refs, nil 76 | } 77 | 78 | // IsURLAccessible returns true if given remote URL is accessible via Git within 79 | // given timeout. 80 | func IsURLAccessible(timeout time.Duration, url string) bool { 81 | _, err := LsRemote(url, LsRemoteOptions{ 82 | Patterns: []string{"HEAD"}, 83 | Timeout: timeout, 84 | }) 85 | return err == nil 86 | } 87 | 88 | // RemoteAddOptions contains options to add a remote address. 89 | // 90 | // Docs: https://git-scm.com/docs/git-remote#Documentation/git-remote.txt-emaddem 91 | type RemoteAddOptions struct { 92 | // Indicates whether to execute git fetch after the remote information is set 93 | // up. 94 | Fetch bool 95 | // Indicates whether to add remote as mirror with --mirror=fetch. 96 | MirrorFetch bool 97 | // The timeout duration before giving up for each shell command execution. The 98 | // default timeout duration will be used when not supplied. 99 | // 100 | // Deprecated: Use CommandOptions.Timeout instead. 101 | Timeout time.Duration 102 | // The additional options to be passed to the underlying git. 103 | CommandOptions 104 | } 105 | 106 | // Deprecated: Use RemoteAddOptions instead. 107 | type AddRemoteOptions = RemoteAddOptions 108 | 109 | // RemoteAdd adds a new remote to the repository in given path. 110 | func RemoteAdd(repoPath, name, url string, opts ...RemoteAddOptions) error { 111 | var opt RemoteAddOptions 112 | if len(opts) > 0 { 113 | opt = opts[0] 114 | } 115 | 116 | cmd := NewCommand("remote", "add").AddOptions(opt.CommandOptions) 117 | if opt.Fetch { 118 | cmd.AddArgs("-f") 119 | } 120 | if opt.MirrorFetch { 121 | cmd.AddArgs("--mirror=fetch") 122 | } 123 | 124 | _, err := cmd.AddArgs(name, url).RunInDirWithTimeout(opt.Timeout, repoPath) 125 | return err 126 | } 127 | 128 | // Deprecated: Use RemoteAdd instead. 129 | func RepoAddRemote(repoPath, name, url string, opts ...RemoteAddOptions) error { 130 | return RemoteAdd(repoPath, name, url, opts...) 131 | } 132 | 133 | // RemoteAdd adds a new remote to the repository. 134 | func (r *Repository) RemoteAdd(name, url string, opts ...RemoteAddOptions) error { 135 | return RemoteAdd(r.path, name, url, opts...) 136 | } 137 | 138 | // Deprecated: Use RemoteAdd instead. 139 | func (r *Repository) AddRemote(name, url string, opts ...RemoteAddOptions) error { 140 | return RemoteAdd(r.path, name, url, opts...) 141 | } 142 | 143 | // RemoteRemoveOptions contains arguments for removing a remote from the 144 | // repository. 145 | // 146 | // Docs: https://git-scm.com/docs/git-remote#Documentation/git-remote.txt-emremoveem 147 | type RemoteRemoveOptions struct { 148 | // The timeout duration before giving up for each shell command execution. The 149 | // default timeout duration will be used when not supplied. 150 | // 151 | // Deprecated: Use CommandOptions.Timeout instead. 152 | Timeout time.Duration 153 | // The additional options to be passed to the underlying git. 154 | CommandOptions 155 | } 156 | 157 | // Deprecated: Use RemoteRemoveOptions instead. 158 | type RemoveRemoteOptions = RemoteRemoveOptions 159 | 160 | // RemoteRemove removes a remote from the repository in given path. 161 | func RemoteRemove(repoPath, name string, opts ...RemoteRemoveOptions) error { 162 | var opt RemoteRemoveOptions 163 | if len(opts) > 0 { 164 | opt = opts[0] 165 | } 166 | 167 | _, err := NewCommand("remote", "remove"). 168 | AddOptions(opt.CommandOptions). 169 | AddArgs(name). 170 | RunInDirWithTimeout(opt.Timeout, repoPath) 171 | if err != nil { 172 | // the error status may differ from git clients 173 | if strings.Contains(err.Error(), "error: No such remote") || 174 | strings.Contains(err.Error(), "fatal: No such remote") { 175 | return ErrRemoteNotExist 176 | } 177 | return err 178 | } 179 | return nil 180 | } 181 | 182 | // Deprecated: Use RemoteRemove instead. 183 | func RepoRemoveRemote(repoPath, name string, opts ...RemoteRemoveOptions) error { 184 | return RemoteRemove(repoPath, name, opts...) 185 | } 186 | 187 | // RemoteRemove removes a remote from the repository. 188 | func (r *Repository) RemoteRemove(name string, opts ...RemoteRemoveOptions) error { 189 | return RemoteRemove(r.path, name, opts...) 190 | } 191 | 192 | // Deprecated: Use RemoteRemove instead. 193 | func (r *Repository) RemoveRemote(name string, opts ...RemoteRemoveOptions) error { 194 | return RemoteRemove(r.path, name, opts...) 195 | } 196 | 197 | // RemotesOptions contains arguments for listing remotes of the repository. 198 | // / 199 | // Docs: https://git-scm.com/docs/git-remote#_commands 200 | type RemotesOptions struct { 201 | // The timeout duration before giving up for each shell command execution. The 202 | // default timeout duration will be used when not supplied. 203 | // 204 | // Deprecated: Use CommandOptions.Timeout instead. 205 | Timeout time.Duration 206 | // The additional options to be passed to the underlying git. 207 | CommandOptions 208 | } 209 | 210 | // Remotes lists remotes of the repository in given path. 211 | func Remotes(repoPath string, opts ...RemotesOptions) ([]string, error) { 212 | var opt RemotesOptions 213 | if len(opts) > 0 { 214 | opt = opts[0] 215 | } 216 | 217 | stdout, err := NewCommand("remote"). 218 | AddOptions(opt.CommandOptions). 219 | RunInDirWithTimeout(opt.Timeout, repoPath) 220 | if err != nil { 221 | return nil, err 222 | } 223 | 224 | return bytesToStrings(stdout), nil 225 | } 226 | 227 | // Remotes lists remotes of the repository. 228 | func (r *Repository) Remotes(opts ...RemotesOptions) ([]string, error) { 229 | return Remotes(r.path, opts...) 230 | } 231 | 232 | // RemoteGetURLOptions contains arguments for retrieving URL(s) of a remote of 233 | // the repository. 234 | // 235 | // Docs: https://git-scm.com/docs/git-remote#Documentation/git-remote.txt-emget-urlem 236 | type RemoteGetURLOptions struct { 237 | // Indicates whether to get push URLs instead of fetch URLs. 238 | Push bool 239 | // Indicates whether to get all URLs, including lists that are not part of main 240 | // URLs. This option is independent of the Push option. 241 | All bool 242 | // The timeout duration before giving up for each shell command execution. The 243 | // default timeout duration will be used when not supplied. 244 | // 245 | // Deprecated: Use CommandOptions.Timeout instead. 246 | Timeout time.Duration 247 | // The additional options to be passed to the underlying git. 248 | CommandOptions 249 | } 250 | 251 | // RemoteGetURL retrieves URL(s) of a remote of the repository in given path. 252 | func RemoteGetURL(repoPath, name string, opts ...RemoteGetURLOptions) ([]string, error) { 253 | var opt RemoteGetURLOptions 254 | if len(opts) > 0 { 255 | opt = opts[0] 256 | } 257 | 258 | cmd := NewCommand("remote", "get-url").AddOptions(opt.CommandOptions) 259 | if opt.Push { 260 | cmd.AddArgs("--push") 261 | } 262 | if opt.All { 263 | cmd.AddArgs("--all") 264 | } 265 | 266 | stdout, err := cmd.AddArgs(name).RunInDirWithTimeout(opt.Timeout, repoPath) 267 | if err != nil { 268 | return nil, err 269 | } 270 | return bytesToStrings(stdout), nil 271 | } 272 | 273 | // RemoteGetURL retrieves URL(s) of a remote of the repository in given path. 274 | func (r *Repository) RemoteGetURL(name string, opts ...RemoteGetURLOptions) ([]string, error) { 275 | return RemoteGetURL(r.path, name, opts...) 276 | } 277 | 278 | // RemoteSetURLOptions contains arguments for setting an URL of a remote of the 279 | // repository. 280 | // 281 | // Docs: https://git-scm.com/docs/git-remote#Documentation/git-remote.txt-emset-urlem 282 | type RemoteSetURLOptions struct { 283 | // Indicates whether to get push URLs instead of fetch URLs. 284 | Push bool 285 | // The regex to match existing URLs to replace (instead of first). 286 | Regex string 287 | // The timeout duration before giving up for each shell command execution. The 288 | // default timeout duration will be used when not supplied. 289 | // 290 | // Deprecated: Use CommandOptions.Timeout instead. 291 | Timeout time.Duration 292 | // The additional options to be passed to the underlying git. 293 | CommandOptions 294 | } 295 | 296 | // RemoteSetURL sets first URL of the remote with given name of the repository 297 | // in given path. 298 | func RemoteSetURL(repoPath, name, newurl string, opts ...RemoteSetURLOptions) error { 299 | var opt RemoteSetURLOptions 300 | if len(opts) > 0 { 301 | opt = opts[0] 302 | } 303 | 304 | cmd := NewCommand("remote", "set-url").AddOptions(opt.CommandOptions) 305 | if opt.Push { 306 | cmd.AddArgs("--push") 307 | } 308 | 309 | cmd.AddArgs(name, newurl) 310 | 311 | if opt.Regex != "" { 312 | cmd.AddArgs(opt.Regex) 313 | } 314 | 315 | _, err := cmd.RunInDirWithTimeout(opt.Timeout, repoPath) 316 | if err != nil { 317 | if strings.Contains(err.Error(), "No such URL found") { 318 | return ErrURLNotExist 319 | } else if strings.Contains(err.Error(), "No such remote") { 320 | return ErrRemoteNotExist 321 | } 322 | return err 323 | } 324 | return nil 325 | } 326 | 327 | // RemoteSetURL sets the first URL of the remote with given name of the 328 | // repository. 329 | func (r *Repository) RemoteSetURL(name, newurl string, opts ...RemoteSetURLOptions) error { 330 | return RemoteSetURL(r.path, name, newurl, opts...) 331 | } 332 | 333 | // RemoteSetURLAddOptions contains arguments for appending an URL to a remote 334 | // of the repository. 335 | // 336 | // Docs: https://git-scm.com/docs/git-remote#Documentation/git-remote.txt-emset-urlem 337 | type RemoteSetURLAddOptions struct { 338 | // Indicates whether to get push URLs instead of fetch URLs. 339 | Push bool 340 | // The timeout duration before giving up for each shell command execution. The 341 | // default timeout duration will be used when not supplied. 342 | // 343 | // Deprecated: Use CommandOptions.Timeout instead. 344 | Timeout time.Duration 345 | // The additional options to be passed to the underlying git. 346 | CommandOptions 347 | } 348 | 349 | // RemoteSetURLAdd appends an URL to the remote with given name of the 350 | // repository in given path. Use RemoteSetURL to overwrite the URL(s) instead. 351 | func RemoteSetURLAdd(repoPath, name, newurl string, opts ...RemoteSetURLAddOptions) error { 352 | var opt RemoteSetURLAddOptions 353 | if len(opts) > 0 { 354 | opt = opts[0] 355 | } 356 | 357 | cmd := NewCommand("remote", "set-url"). 358 | AddOptions(opt.CommandOptions). 359 | AddArgs("--add") 360 | if opt.Push { 361 | cmd.AddArgs("--push") 362 | } 363 | 364 | cmd.AddArgs(name, newurl) 365 | 366 | _, err := cmd.RunInDirWithTimeout(opt.Timeout, repoPath) 367 | if err != nil && strings.Contains(err.Error(), "Will not delete all non-push URLs") { 368 | return ErrNotDeleteNonPushURLs 369 | } 370 | return err 371 | } 372 | 373 | // RemoteSetURLAdd appends an URL to the remote with given name of the 374 | // repository. Use RemoteSetURL to overwrite the URL(s) instead. 375 | func (r *Repository) RemoteSetURLAdd(name, newurl string, opts ...RemoteSetURLAddOptions) error { 376 | return RemoteSetURLAdd(r.path, name, newurl, opts...) 377 | } 378 | 379 | // RemoteSetURLDeleteOptions contains arguments for deleting an URL of a remote 380 | // of the repository. 381 | // 382 | // Docs: https://git-scm.com/docs/git-remote#Documentation/git-remote.txt-emset-urlem 383 | type RemoteSetURLDeleteOptions struct { 384 | // Indicates whether to get push URLs instead of fetch URLs. 385 | Push bool 386 | // The timeout duration before giving up for each shell command execution. The 387 | // default timeout duration will be used when not supplied. 388 | // 389 | // Deprecated: Use CommandOptions.Timeout instead. 390 | Timeout time.Duration 391 | // The additional options to be passed to the underlying git. 392 | CommandOptions 393 | } 394 | 395 | // RemoteSetURLDelete deletes the remote with given name of the repository in 396 | // given path. 397 | func RemoteSetURLDelete(repoPath, name, regex string, opts ...RemoteSetURLDeleteOptions) error { 398 | var opt RemoteSetURLDeleteOptions 399 | if len(opts) > 0 { 400 | opt = opts[0] 401 | } 402 | 403 | cmd := NewCommand("remote", "set-url"). 404 | AddOptions(opt.CommandOptions). 405 | AddArgs("--delete") 406 | if opt.Push { 407 | cmd.AddArgs("--push") 408 | } 409 | 410 | cmd.AddArgs(name, regex) 411 | 412 | _, err := cmd.RunInDirWithTimeout(opt.Timeout, repoPath) 413 | if err != nil && strings.Contains(err.Error(), "Will not delete all non-push URLs") { 414 | return ErrNotDeleteNonPushURLs 415 | } 416 | return err 417 | } 418 | 419 | // RemoteSetURLDelete deletes all URLs matching regex of the remote with given 420 | // name of the repository. 421 | func (r *Repository) RemoteSetURLDelete(name, regex string, opts ...RemoteSetURLDeleteOptions) error { 422 | return RemoteSetURLDelete(r.path, name, regex, opts...) 423 | } 424 | -------------------------------------------------------------------------------- /repo_remote_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "os" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestLsRemote(t *testing.T) { 15 | tests := []struct { 16 | url string 17 | opt LsRemoteOptions 18 | expRefs []*Reference 19 | }{ 20 | { 21 | url: testrepo.Path(), 22 | opt: LsRemoteOptions{ 23 | Heads: true, 24 | Patterns: []string{"release-1.0"}, 25 | }, 26 | expRefs: []*Reference{ 27 | { 28 | ID: "0eedd79eba4394bbef888c804e899731644367fe", 29 | Refspec: "refs/heads/release-1.0", 30 | }, 31 | }, 32 | }, { 33 | url: testrepo.Path(), 34 | opt: LsRemoteOptions{ 35 | Tags: true, 36 | Patterns: []string{"v1.0.0"}, 37 | }, 38 | expRefs: []*Reference{ 39 | { 40 | ID: "0eedd79eba4394bbef888c804e899731644367fe", 41 | Refspec: "refs/tags/v1.0.0", 42 | }, 43 | }, 44 | }, { 45 | url: testrepo.Path(), 46 | opt: LsRemoteOptions{ 47 | Refs: true, 48 | Patterns: []string{"v1.0.0"}, 49 | }, 50 | expRefs: []*Reference{ 51 | { 52 | ID: "0eedd79eba4394bbef888c804e899731644367fe", 53 | Refspec: "refs/tags/v1.0.0", 54 | }, 55 | }, 56 | }, 57 | } 58 | for _, test := range tests { 59 | t.Run("", func(t *testing.T) { 60 | refs, err := LsRemote(test.url, test.opt) 61 | if err != nil { 62 | t.Fatal(err) 63 | } 64 | 65 | assert.Equal(t, test.expRefs, refs) 66 | }) 67 | } 68 | } 69 | 70 | func TestIsURLAccessible(t *testing.T) { 71 | tests := []struct { 72 | url string 73 | expVal bool 74 | }{ 75 | { 76 | url: testrepo.Path(), 77 | expVal: true, 78 | }, { 79 | url: os.TempDir(), 80 | expVal: false, 81 | }, 82 | } 83 | for _, test := range tests { 84 | t.Run("", func(t *testing.T) { 85 | assert.Equal(t, test.expVal, IsURLAccessible(DefaultTimeout, test.url)) 86 | }) 87 | } 88 | } 89 | 90 | func TestRepository_RemoteAdd(t *testing.T) { 91 | path := tempPath() 92 | defer func() { 93 | _ = os.RemoveAll(path) 94 | }() 95 | 96 | err := Init(path, InitOptions{ 97 | Bare: true, 98 | }) 99 | if err != nil { 100 | t.Fatal(err) 101 | } 102 | 103 | r, err := Open(path) 104 | if err != nil { 105 | t.Fatal(err) 106 | } 107 | 108 | // Add testrepo as the mirror remote and fetch right away 109 | err = r.RemoteAdd("origin", testrepo.Path(), RemoteAddOptions{ 110 | Fetch: true, 111 | MirrorFetch: true, 112 | }) 113 | if err != nil { 114 | t.Fatal(err) 115 | } 116 | 117 | // Check a non-default branch: release-1.0 118 | assert.True(t, r.HasReference(RefsHeads+"release-1.0")) 119 | } 120 | 121 | func TestRepository_RemoteRemove(t *testing.T) { 122 | r, cleanup, err := setupTempRepo() 123 | if err != nil { 124 | t.Fatal(err) 125 | } 126 | defer cleanup() 127 | 128 | err = r.RemoteRemove("origin", RemoteRemoveOptions{}) 129 | assert.Nil(t, err) 130 | 131 | err = r.RemoteRemove("origin", RemoteRemoveOptions{}) 132 | assert.Equal(t, ErrRemoteNotExist, err) 133 | } 134 | 135 | func TestRepository_Remotes(t *testing.T) { 136 | r, cleanup, err := setupTempRepo() 137 | if err != nil { 138 | t.Fatal(err) 139 | } 140 | defer cleanup() 141 | 142 | // 1 remote 143 | remotes, err := r.Remotes() 144 | assert.Nil(t, err) 145 | assert.Equal(t, []string{"origin"}, remotes) 146 | 147 | // 2 remotes 148 | err = r.RemoteAdd("t", "t") 149 | assert.Nil(t, err) 150 | 151 | remotes, err = r.Remotes() 152 | assert.Nil(t, err) 153 | assert.Equal(t, []string{"origin", "t"}, remotes) 154 | assert.Len(t, remotes, 2) 155 | 156 | // 0 remotes 157 | err = r.RemoteRemove("t") 158 | assert.Nil(t, err) 159 | err = r.RemoteRemove("origin") 160 | assert.Nil(t, err) 161 | 162 | remotes, err = r.Remotes() 163 | assert.Nil(t, err) 164 | assert.Equal(t, []string{}, remotes) 165 | assert.Len(t, remotes, 0) 166 | } 167 | 168 | func TestRepository_RemoteURLFamily(t *testing.T) { 169 | r, cleanup, err := setupTempRepo() 170 | if err != nil { 171 | t.Fatal(err) 172 | } 173 | defer cleanup() 174 | 175 | err = r.RemoteSetURLDelete("origin", ".*") 176 | assert.Equal(t, ErrNotDeleteNonPushURLs, err) 177 | 178 | err = r.RemoteSetURL("notexist", "t") 179 | assert.Equal(t, ErrRemoteNotExist, err) 180 | 181 | err = r.RemoteSetURL("notexist", "t", RemoteSetURLOptions{Regex: "t"}) 182 | assert.Equal(t, ErrRemoteNotExist, err) 183 | 184 | // Default origin URL is not easily testable 185 | err = r.RemoteSetURL("origin", "t") 186 | assert.Nil(t, err) 187 | urls, err := r.RemoteGetURL("origin") 188 | assert.Nil(t, err) 189 | assert.Equal(t, []string{"t"}, urls) 190 | 191 | err = r.RemoteSetURLAdd("origin", "e") 192 | assert.Nil(t, err) 193 | urls, err = r.RemoteGetURL("origin", RemoteGetURLOptions{All: true}) 194 | assert.Nil(t, err) 195 | assert.Equal(t, []string{"t", "e"}, urls) 196 | 197 | err = r.RemoteSetURL("origin", "s", RemoteSetURLOptions{Regex: "e"}) 198 | assert.Nil(t, err) 199 | urls, err = r.RemoteGetURL("origin", RemoteGetURLOptions{All: true}) 200 | assert.Nil(t, err) 201 | assert.Equal(t, []string{"t", "s"}, urls) 202 | 203 | err = r.RemoteSetURLDelete("origin", "t") 204 | assert.Nil(t, err) 205 | urls, err = r.RemoteGetURL("origin", RemoteGetURLOptions{All: true}) 206 | assert.Nil(t, err) 207 | assert.Equal(t, []string{"s"}, urls) 208 | } 209 | -------------------------------------------------------------------------------- /repo_tag.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "bytes" 9 | "fmt" 10 | "strings" 11 | "time" 12 | 13 | goversion "github.com/mcuadros/go-version" 14 | ) 15 | 16 | // parseTag parses tag information from the (uncompressed) raw data of the tag 17 | // object. It assumes "\n\n" separates the header from the rest of the message. 18 | func parseTag(data []byte) (*Tag, error) { 19 | tag := new(Tag) 20 | // we now have the contents of the commit object. Let's investigate. 21 | nextline := 0 22 | l: 23 | for { 24 | eol := bytes.IndexByte(data[nextline:], '\n') 25 | switch { 26 | case eol > 0: 27 | line := data[nextline : nextline+eol] 28 | spacepos := bytes.IndexByte(line, ' ') 29 | reftype := line[:spacepos] 30 | switch string(reftype) { 31 | case "object": 32 | id, err := NewIDFromString(string(line[spacepos+1:])) 33 | if err != nil { 34 | return nil, err 35 | } 36 | tag.commitID = id 37 | case "type": 38 | case "tagger": 39 | sig, err := parseSignature(line[spacepos+1:]) 40 | if err != nil { 41 | return nil, err 42 | } 43 | tag.tagger = sig 44 | } 45 | nextline += eol + 1 46 | case eol == 0: 47 | tag.message = string(data[nextline+1:]) 48 | break l 49 | default: 50 | break l 51 | } 52 | } 53 | return tag, nil 54 | } 55 | 56 | // getTag returns a tag by given SHA1 hash. 57 | func (r *Repository) getTag(timeout time.Duration, id *SHA1) (*Tag, error) { 58 | t, ok := r.cachedTags.Get(id.String()) 59 | if ok { 60 | log("Cached tag hit: %s", id) 61 | return t.(*Tag), nil 62 | } 63 | 64 | // Check tag type 65 | typ, err := r.CatFileType(id.String(), CatFileTypeOptions{Timeout: timeout}) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | var tag *Tag 71 | switch typ { 72 | case ObjectCommit: // Tag is a commit 73 | tag = &Tag{ 74 | typ: ObjectCommit, 75 | id: id, 76 | commitID: id, 77 | repo: r, 78 | } 79 | 80 | case ObjectTag: // Tag is an annotation 81 | data, err := NewCommand("cat-file", "-p", id.String()).RunInDir(r.path) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | tag, err = parseTag(data) 87 | if err != nil { 88 | return nil, err 89 | } 90 | tag.typ = ObjectTag 91 | tag.id = id 92 | tag.repo = r 93 | default: 94 | return nil, fmt.Errorf("unsupported tag type: %s", typ) 95 | } 96 | 97 | r.cachedTags.Set(id.String(), tag) 98 | return tag, nil 99 | } 100 | 101 | // TagOptions contains optional arguments for getting a tag. 102 | // 103 | // Docs: https://git-scm.com/docs/git-cat-file 104 | type TagOptions struct { 105 | // The timeout duration before giving up for each shell command execution. The 106 | // default timeout duration will be used when not supplied. 107 | // 108 | // Deprecated: Use CommandOptions.Timeout instead. 109 | Timeout time.Duration 110 | // The additional options to be passed to the underlying git. 111 | CommandOptions 112 | } 113 | 114 | // Tag returns a Git tag by given name, e.g. "v1.0.0". 115 | func (r *Repository) Tag(name string, opts ...TagOptions) (*Tag, error) { 116 | var opt TagOptions 117 | if len(opts) > 0 { 118 | opt = opts[0] 119 | } 120 | 121 | refsepc := RefsTags + name 122 | refs, err := r.ShowRef(ShowRefOptions{ 123 | Tags: true, 124 | Patterns: []string{refsepc}, 125 | Timeout: opt.Timeout, 126 | CommandOptions: opt.CommandOptions, 127 | }) 128 | if err != nil { 129 | return nil, err 130 | } else if len(refs) == 0 { 131 | return nil, ErrReferenceNotExist 132 | } 133 | 134 | id, err := NewIDFromString(refs[0].ID) 135 | if err != nil { 136 | return nil, err 137 | } 138 | 139 | tag, err := r.getTag(opt.Timeout, id) 140 | if err != nil { 141 | return nil, err 142 | } 143 | tag.refspec = refsepc 144 | return tag, nil 145 | } 146 | 147 | // TagsOptions contains optional arguments for listing tags. 148 | // 149 | // Docs: https://git-scm.com/docs/git-tag#Documentation/git-tag.txt---list 150 | type TagsOptions struct { 151 | // SortKet sorts tags with provided tag key, optionally prefixed with '-' to sort tags in descending order. 152 | SortKey string 153 | // Pattern filters tags matching the specified pattern. 154 | Pattern string 155 | // The timeout duration before giving up for each shell command execution. The 156 | // default timeout duration will be used when not supplied. 157 | // 158 | // Deprecated: Use CommandOptions.Timeout instead. 159 | Timeout time.Duration 160 | // The additional options to be passed to the underlying git. 161 | CommandOptions 162 | } 163 | 164 | // RepoTags returns a list of tags of the repository in given path. 165 | func RepoTags(repoPath string, opts ...TagsOptions) ([]string, error) { 166 | var opt TagsOptions 167 | if len(opts) > 0 { 168 | opt = opts[0] 169 | } 170 | 171 | version, err := BinVersion() 172 | if err != nil { 173 | return nil, err 174 | } 175 | 176 | cmd := NewCommand("tag", "--list").AddOptions(opt.CommandOptions) 177 | 178 | var sorted bool 179 | if opt.SortKey != "" { 180 | cmd.AddArgs("--sort=" + opt.SortKey) 181 | sorted = true 182 | } else if goversion.Compare(version, "2.4.9", ">=") { 183 | cmd.AddArgs("--sort=-creatordate") 184 | sorted = true 185 | } 186 | 187 | if opt.Pattern != "" { 188 | cmd.AddArgs(opt.Pattern) 189 | } 190 | 191 | stdout, err := cmd.RunInDirWithTimeout(opt.Timeout, repoPath) 192 | if err != nil { 193 | return nil, err 194 | } 195 | 196 | tags := strings.Split(string(stdout), "\n") 197 | tags = tags[:len(tags)-1] 198 | 199 | if !sorted { 200 | goversion.Sort(tags) 201 | 202 | // Reverse order 203 | for i := 0; i < len(tags)/2; i++ { 204 | j := len(tags) - i - 1 205 | tags[i], tags[j] = tags[j], tags[i] 206 | } 207 | } 208 | 209 | return tags, nil 210 | } 211 | 212 | // Tags returns a list of tags of the repository. 213 | func (r *Repository) Tags(opts ...TagsOptions) ([]string, error) { 214 | return RepoTags(r.path, opts...) 215 | } 216 | 217 | // CreateTagOptions contains optional arguments for creating a tag. 218 | // 219 | // Docs: https://git-scm.com/docs/git-tag 220 | type CreateTagOptions struct { 221 | // Annotated marks a tag as annotated rather than lightweight. 222 | Annotated bool 223 | // Message specifies a tagging message for the annotated tag. It is ignored when tag is not annotated. 224 | Message string 225 | // Author is the author of the tag. It is ignored when tag is not annotated. 226 | Author *Signature 227 | // The timeout duration before giving up for each shell command execution. The 228 | // default timeout duration will be used when not supplied. 229 | // 230 | // Deprecated: Use CommandOptions.Timeout instead. 231 | Timeout time.Duration 232 | // The additional options to be passed to the underlying git. 233 | CommandOptions 234 | } 235 | 236 | // CreateTag creates a new tag on given revision. 237 | func (r *Repository) CreateTag(name, rev string, opts ...CreateTagOptions) error { 238 | var opt CreateTagOptions 239 | if len(opts) > 0 { 240 | opt = opts[0] 241 | } 242 | 243 | cmd := NewCommand("tag").AddOptions(opt.CommandOptions) 244 | if opt.Annotated { 245 | cmd.AddArgs("-a", name) 246 | cmd.AddArgs("--message", opt.Message) 247 | if opt.Author != nil { 248 | cmd.AddCommitter(opt.Author) 249 | } 250 | } else { 251 | // 🚨 SECURITY: Prevent including unintended options in the path to the Git command. 252 | cmd.AddArgs("--end-of-options") 253 | cmd.AddArgs(name) 254 | } 255 | 256 | cmd.AddArgs(rev) 257 | 258 | _, err := cmd.RunInDirWithTimeout(opt.Timeout, r.path) 259 | return err 260 | } 261 | 262 | // DeleteTagOptions contains optional arguments for deleting a tag. 263 | // 264 | // Docs: https://git-scm.com/docs/git-tag#Documentation/git-tag.txt---delete 265 | type DeleteTagOptions struct { 266 | // The timeout duration before giving up for each shell command execution. 267 | // The default timeout duration will be used when not supplied. 268 | // 269 | // Deprecated: Use CommandOptions.Timeout instead. 270 | Timeout time.Duration 271 | // The additional options to be passed to the underlying git. 272 | CommandOptions 273 | } 274 | 275 | // DeleteTag deletes a tag from the repository. 276 | func (r *Repository) DeleteTag(name string, opts ...DeleteTagOptions) error { 277 | var opt DeleteTagOptions 278 | if len(opts) > 0 { 279 | opt = opts[0] 280 | } 281 | 282 | _, err := NewCommand("tag", "--delete", name). 283 | AddOptions(opt.CommandOptions). 284 | RunInDirWithTimeout(opt.Timeout, r.path) 285 | return err 286 | } 287 | -------------------------------------------------------------------------------- /repo_tag_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestRepository_Tag(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | opt TagOptions 17 | expTag *Tag 18 | }{ 19 | { 20 | name: "v1.0.0", 21 | expTag: &Tag{ 22 | typ: ObjectCommit, 23 | id: MustIDFromString("0eedd79eba4394bbef888c804e899731644367fe"), 24 | commitID: MustIDFromString("0eedd79eba4394bbef888c804e899731644367fe"), 25 | refspec: "refs/tags/v1.0.0", 26 | }, 27 | }, { 28 | name: "v1.1.0", 29 | expTag: &Tag{ 30 | typ: ObjectTag, 31 | id: MustIDFromString("b39c8508bbc4b00ad2e24d358012ea123bcafd8d"), 32 | commitID: MustIDFromString("0eedd79eba4394bbef888c804e899731644367fe"), 33 | refspec: "refs/tags/v1.1.0", 34 | }, 35 | }, 36 | } 37 | for _, test := range tests { 38 | t.Run("", func(t *testing.T) { 39 | tag, err := testrepo.Tag(test.name, test.opt) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | assert.Equal(t, test.expTag.Type(), tag.Type()) 45 | assert.Equal(t, test.expTag.ID().String(), tag.ID().String()) 46 | assert.Equal(t, test.expTag.CommitID().String(), tag.CommitID().String()) 47 | assert.Equal(t, test.expTag.Refspec(), tag.Refspec()) 48 | }) 49 | } 50 | } 51 | 52 | func TestRepository_Tags(t *testing.T) { 53 | // Make sure it does not blow up 54 | tags, err := testrepo.Tags(TagsOptions{}) 55 | if err != nil { 56 | t.Fatal(err) 57 | } 58 | assert.NotEmpty(t, tags) 59 | } 60 | 61 | func TestRepository_Tags_VersionSort(t *testing.T) { 62 | r, cleanup, err := setupTempRepo() 63 | if err != nil { 64 | t.Fatal(err) 65 | } 66 | defer cleanup() 67 | 68 | err = r.CreateTag("v3.0.0", "master") 69 | if err != nil { 70 | t.Fatal(err) 71 | } 72 | err = r.CreateTag("v2.999.0", "master") 73 | if err != nil { 74 | t.Fatal(err) 75 | } 76 | 77 | tags, err := r.Tags(TagsOptions{ 78 | SortKey: "-version:refname", 79 | Pattern: "v*", 80 | }) 81 | if err != nil { 82 | t.Fatal(err) 83 | } 84 | 85 | if len(tags) < 2 { 86 | t.Fatalf("Should have at least two tags but got %d", len(tags)) 87 | } 88 | assert.Equal(t, "v3.0.0", tags[0]) 89 | assert.Equal(t, "v2.999.0", tags[1]) 90 | } 91 | 92 | func TestRepository_CreateTag(t *testing.T) { 93 | r, cleanup, err := setupTempRepo() 94 | if err != nil { 95 | t.Fatal(err) 96 | } 97 | defer cleanup() 98 | 99 | assert.False(t, r.HasReference(RefsTags+"v2.0.0")) 100 | 101 | err = r.CreateTag("v2.0.0", "master", CreateTagOptions{}) 102 | if err != nil { 103 | t.Fatal(err) 104 | } 105 | 106 | assert.True(t, r.HasReference(RefsTags+"v2.0.0")) 107 | } 108 | 109 | func TestRepository_CreateAnnotatedTag(t *testing.T) { 110 | r, cleanup, err := setupTempRepo() 111 | if err != nil { 112 | t.Fatal(err) 113 | } 114 | defer cleanup() 115 | 116 | assert.False(t, r.HasReference(RefsTags+"v2.0.0")) 117 | 118 | err = r.CreateTag("v2.0.0", "master", CreateTagOptions{ 119 | Annotated: true, 120 | Author: &Signature{ 121 | Name: "alice", 122 | Email: "alice@example.com", 123 | }, 124 | }) 125 | if err != nil { 126 | t.Fatal(err) 127 | } 128 | 129 | assert.True(t, r.HasReference(RefsTags+"v2.0.0")) 130 | 131 | tag, err := r.Tag("v2.0.0") 132 | if err != nil { 133 | t.Fatal(err) 134 | } 135 | 136 | assert.Equal(t, "alice", tag.tagger.Name) 137 | assert.Equal(t, "alice@example.com", tag.tagger.Email) 138 | assert.False(t, tag.tagger.When.IsZero()) 139 | } 140 | 141 | func TestRepository_DeleteTag(t *testing.T) { 142 | r, cleanup, err := setupTempRepo() 143 | if err != nil { 144 | t.Fatal(err) 145 | } 146 | defer cleanup() 147 | 148 | assert.True(t, r.HasReference(RefsTags+"v1.0.0")) 149 | 150 | err = r.DeleteTag("v1.0.0", DeleteTagOptions{}) 151 | if err != nil { 152 | t.Fatal(err) 153 | } 154 | 155 | assert.False(t, r.HasReference(RefsTags+"v1.0.0")) 156 | } 157 | -------------------------------------------------------------------------------- /repo_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "io/ioutil" 9 | "os" 10 | "path/filepath" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestRepository(t *testing.T) { 17 | path := os.TempDir() 18 | r := &Repository{ 19 | path: path, 20 | } 21 | 22 | assert.Equal(t, path, r.Path()) 23 | } 24 | 25 | func TestInit(t *testing.T) { 26 | tests := []struct { 27 | opt InitOptions 28 | }{ 29 | { 30 | opt: InitOptions{}, 31 | }, 32 | { 33 | opt: InitOptions{ 34 | Bare: true, 35 | }, 36 | }, 37 | } 38 | for _, test := range tests { 39 | t.Run("", func(t *testing.T) { 40 | // Make sure it does not blow up 41 | path := tempPath() 42 | defer func() { 43 | _ = os.RemoveAll(path) 44 | }() 45 | 46 | if err := Init(path, test.opt); err != nil { 47 | t.Fatal(err) 48 | } 49 | }) 50 | } 51 | } 52 | 53 | func TestOpen(t *testing.T) { 54 | _, err := Open(testrepo.Path()) 55 | assert.Nil(t, err) 56 | 57 | _, err = Open(tempPath()) 58 | assert.Equal(t, os.ErrNotExist, err) 59 | } 60 | 61 | func TestClone(t *testing.T) { 62 | tests := []struct { 63 | opt CloneOptions 64 | }{ 65 | { 66 | opt: CloneOptions{}, 67 | }, 68 | { 69 | opt: CloneOptions{ 70 | Mirror: true, 71 | Bare: true, 72 | Quiet: true, 73 | }, 74 | }, 75 | { 76 | opt: CloneOptions{ 77 | Branch: "develop", 78 | }, 79 | }, 80 | { 81 | opt: CloneOptions{ 82 | Depth: 1, 83 | }, 84 | }, 85 | { 86 | opt: CloneOptions{ 87 | Branch: "develop", 88 | Depth: 1, 89 | }, 90 | }, 91 | } 92 | for _, test := range tests { 93 | t.Run("", func(t *testing.T) { 94 | // Make sure it does not blow up 95 | path := tempPath() 96 | defer func() { 97 | _ = os.RemoveAll(path) 98 | }() 99 | 100 | if err := Clone(testrepo.Path(), path, test.opt); err != nil { 101 | t.Fatal(err) 102 | } 103 | }) 104 | } 105 | } 106 | 107 | func setupTempRepo() (_ *Repository, cleanup func(), err error) { 108 | path := tempPath() 109 | cleanup = func() { 110 | _ = os.RemoveAll(path) 111 | } 112 | defer func() { 113 | if err != nil { 114 | cleanup() 115 | } 116 | }() 117 | 118 | if err = Clone(testrepo.Path(), path); err != nil { 119 | return nil, cleanup, err 120 | } 121 | 122 | r, err := Open(path) 123 | if err != nil { 124 | return nil, cleanup, err 125 | } 126 | return r, cleanup, nil 127 | } 128 | 129 | func TestRepository_Fetch(t *testing.T) { 130 | r, cleanup, err := setupTempRepo() 131 | if err != nil { 132 | t.Fatal(err) 133 | } 134 | defer cleanup() 135 | 136 | tests := []struct { 137 | opt FetchOptions 138 | }{ 139 | { 140 | opt: FetchOptions{}, 141 | }, 142 | { 143 | opt: FetchOptions{ 144 | Prune: true, 145 | }, 146 | }, 147 | } 148 | for _, test := range tests { 149 | t.Run("", func(t *testing.T) { 150 | // Make sure it does not blow up 151 | if err := r.Fetch(test.opt); err != nil { 152 | t.Fatal(err) 153 | } 154 | }) 155 | } 156 | } 157 | 158 | func TestRepository_Pull(t *testing.T) { 159 | r, cleanup, err := setupTempRepo() 160 | if err != nil { 161 | t.Fatal(err) 162 | } 163 | defer cleanup() 164 | 165 | tests := []struct { 166 | opt PullOptions 167 | }{ 168 | { 169 | opt: PullOptions{}, 170 | }, 171 | { 172 | opt: PullOptions{ 173 | Rebase: true, 174 | }, 175 | }, 176 | { 177 | opt: PullOptions{ 178 | All: true, 179 | }, 180 | }, 181 | { 182 | opt: PullOptions{ 183 | Remote: "origin", 184 | Branch: "master", 185 | }, 186 | }, 187 | } 188 | for _, test := range tests { 189 | t.Run("", func(t *testing.T) { 190 | // Make sure it does not blow up 191 | if err := r.Pull(test.opt); err != nil { 192 | t.Fatal(err) 193 | } 194 | }) 195 | } 196 | } 197 | 198 | func TestRepository_Push(t *testing.T) { 199 | r, cleanup, err := setupTempRepo() 200 | if err != nil { 201 | t.Fatal(err) 202 | } 203 | defer cleanup() 204 | 205 | tests := []struct { 206 | remote string 207 | branch string 208 | opt PushOptions 209 | }{ 210 | { 211 | remote: "origin", 212 | branch: "master", 213 | opt: PushOptions{}, 214 | }, 215 | } 216 | for _, test := range tests { 217 | t.Run("", func(t *testing.T) { 218 | // Make sure it does not blow up 219 | if err := r.Push(test.remote, test.branch, test.opt); err != nil { 220 | t.Fatal(err) 221 | } 222 | }) 223 | } 224 | } 225 | 226 | func TestRepository_Checkout(t *testing.T) { 227 | r, cleanup, err := setupTempRepo() 228 | if err != nil { 229 | t.Fatal(err) 230 | } 231 | defer cleanup() 232 | 233 | tests := []struct { 234 | branch string 235 | opt CheckoutOptions 236 | }{ 237 | { 238 | branch: "develop", 239 | opt: CheckoutOptions{}, 240 | }, 241 | { 242 | branch: "a-new-branch", 243 | opt: CheckoutOptions{ 244 | BaseBranch: "master", 245 | }, 246 | }, 247 | } 248 | for _, test := range tests { 249 | t.Run("", func(t *testing.T) { 250 | // Make sure it does not blow up 251 | if err := r.Checkout(test.branch, test.opt); err != nil { 252 | t.Fatal(err) 253 | } 254 | }) 255 | } 256 | } 257 | 258 | func TestRepository_Reset(t *testing.T) { 259 | r, cleanup, err := setupTempRepo() 260 | if err != nil { 261 | t.Fatal(err) 262 | } 263 | defer cleanup() 264 | 265 | tests := []struct { 266 | rev string 267 | opt ResetOptions 268 | }{ 269 | { 270 | rev: "978fb7f6388b49b532fbef8b856681cfa6fcaa0a", 271 | opt: ResetOptions{ 272 | Hard: true, 273 | }, 274 | }, 275 | } 276 | for _, test := range tests { 277 | t.Run("", func(t *testing.T) { 278 | // Make sure it does not blow up 279 | if err := r.Reset(test.rev, test.opt); err != nil { 280 | t.Fatal(err) 281 | } 282 | }) 283 | } 284 | } 285 | 286 | func TestRepository_Move(t *testing.T) { 287 | r, cleanup, err := setupTempRepo() 288 | if err != nil { 289 | t.Fatal(err) 290 | } 291 | defer cleanup() 292 | 293 | tests := []struct { 294 | src string 295 | dst string 296 | opt MoveOptions 297 | }{ 298 | { 299 | src: "run.sh", 300 | dst: "runme.sh", 301 | opt: MoveOptions{}, 302 | }, 303 | } 304 | for _, test := range tests { 305 | t.Run("", func(t *testing.T) { 306 | // Make sure it does not blow up 307 | if err := r.Move(test.src, test.dst, test.opt); err != nil { 308 | t.Fatal(err) 309 | } 310 | }) 311 | } 312 | } 313 | 314 | func TestRepository_Add(t *testing.T) { 315 | r, cleanup, err := setupTempRepo() 316 | if err != nil { 317 | t.Fatal(err) 318 | } 319 | defer cleanup() 320 | 321 | // Generate a file 322 | fpath := filepath.Join(r.Path(), "TESTFILE") 323 | err = ioutil.WriteFile(fpath, []byte("something"), 0600) 324 | if err != nil { 325 | t.Fatal(err) 326 | } 327 | 328 | // Make sure it does not blow up 329 | if err := r.Add(AddOptions{ 330 | All: true, 331 | Pathspecs: []string{"TESTFILE"}, 332 | }); err != nil { 333 | t.Fatal(err) 334 | } 335 | } 336 | 337 | func TestRepository_Commit(t *testing.T) { 338 | r, cleanup, err := setupTempRepo() 339 | if err != nil { 340 | t.Fatal(err) 341 | } 342 | defer cleanup() 343 | 344 | committer := &Signature{ 345 | Name: "alice", 346 | Email: "alice@example.com", 347 | } 348 | author := &Signature{ 349 | Name: "bob", 350 | Email: "bob@example.com", 351 | } 352 | message := "Add a file" 353 | 354 | t.Run("nothing to commit", func(t *testing.T) { 355 | if err = r.Commit(committer, message, CommitOptions{ 356 | Author: author, 357 | }); err != nil { 358 | t.Fatal(err) 359 | } 360 | }) 361 | 362 | t.Run("committer is also the author", func(t *testing.T) { 363 | // Generate a file and add to index 364 | fpath := filepath.Join(r.Path(), "COMMITTER_IS_AUTHOR") 365 | err = ioutil.WriteFile(fpath, []byte("something"), 0600) 366 | if err != nil { 367 | t.Fatal(err) 368 | } 369 | 370 | if err := r.Add(AddOptions{ 371 | All: true, 372 | }); err != nil { 373 | t.Fatal(err) 374 | } 375 | 376 | // Make sure it does not blow up 377 | if err = r.Commit(committer, message); err != nil { 378 | t.Fatal(err) 379 | } 380 | 381 | // Verify the result 382 | c, err := r.CatFileCommit("master") 383 | if err != nil { 384 | t.Fatal(err) 385 | } 386 | 387 | assert.Equal(t, committer.Name, c.Committer.Name) 388 | assert.Equal(t, committer.Email, c.Committer.Email) 389 | assert.Equal(t, committer.Name, c.Author.Name) 390 | assert.Equal(t, committer.Email, c.Author.Email) 391 | assert.Equal(t, message+"\n", c.Message) 392 | }) 393 | 394 | t.Run("committer is not the author", func(t *testing.T) { 395 | // Generate a file and add to index 396 | fpath := filepath.Join(r.Path(), "COMMITTER_IS_NOT_AUTHOR") 397 | err = ioutil.WriteFile(fpath, []byte("something"), 0600) 398 | if err != nil { 399 | t.Fatal(err) 400 | } 401 | 402 | if err := r.Add(AddOptions{ 403 | All: true, 404 | }); err != nil { 405 | t.Fatal(err) 406 | } 407 | 408 | // Make sure it does not blow up 409 | if err = r.Commit(committer, message, CommitOptions{Author: author}); err != nil { 410 | t.Fatal(err) 411 | } 412 | 413 | // Verify the result 414 | c, err := r.CatFileCommit("master") 415 | if err != nil { 416 | t.Fatal(err) 417 | } 418 | 419 | assert.Equal(t, committer.Name, c.Committer.Name) 420 | assert.Equal(t, committer.Email, c.Committer.Email) 421 | assert.Equal(t, author.Name, c.Author.Name) 422 | assert.Equal(t, author.Email, c.Author.Email) 423 | assert.Equal(t, message+"\n", c.Message) 424 | }) 425 | } 426 | 427 | func TestRepository_RevParse(t *testing.T) { 428 | tests := []struct { 429 | rev string 430 | expID string 431 | expErr error 432 | }{ 433 | { 434 | rev: "4e59b72", 435 | expID: "4e59b72440188e7c2578299fc28ea425fbe9aece", 436 | expErr: nil, 437 | }, 438 | { 439 | rev: "release-1.0", 440 | expID: "0eedd79eba4394bbef888c804e899731644367fe", 441 | expErr: nil, 442 | }, 443 | { 444 | rev: "RELEASE_1.0", 445 | expID: "2a52e96389d02209b451ae1ddf45d645b42d744c", 446 | expErr: nil, 447 | }, 448 | { 449 | rev: "refs/heads/release-1.0", 450 | expID: "0eedd79eba4394bbef888c804e899731644367fe", 451 | expErr: nil, 452 | }, 453 | { 454 | rev: "refs/tags/RELEASE_1.0", 455 | expID: "2a52e96389d02209b451ae1ddf45d645b42d744c", 456 | expErr: nil, 457 | }, 458 | 459 | { 460 | rev: "refs/tags/404", 461 | expID: "", 462 | expErr: ErrRevisionNotExist, 463 | }, 464 | } 465 | for _, test := range tests { 466 | t.Run("", func(t *testing.T) { 467 | id, err := testrepo.RevParse(test.rev) 468 | assert.Equal(t, test.expErr, err) 469 | assert.Equal(t, test.expID, id) 470 | }) 471 | } 472 | } 473 | 474 | func TestRepository_CountObjects(t *testing.T) { 475 | // Make sure it does not blow up 476 | _, err := testrepo.CountObjects(CountObjectsOptions{}) 477 | if err != nil { 478 | t.Fatal(err) 479 | } 480 | } 481 | 482 | func TestRepository_Fsck(t *testing.T) { 483 | // Make sure it does not blow up 484 | err := testrepo.Fsck(FsckOptions{}) 485 | if err != nil { 486 | t.Fatal(err) 487 | } 488 | } 489 | -------------------------------------------------------------------------------- /repo_tree.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "bytes" 9 | "fmt" 10 | "time" 11 | ) 12 | 13 | // UnescapeChars reverses escaped characters. 14 | func UnescapeChars(in []byte) []byte { 15 | if bytes.ContainsAny(in, "\\\t") { 16 | return in 17 | } 18 | 19 | out := bytes.Replace(in, escapedSlash, regularSlash, -1) 20 | out = bytes.Replace(out, escapedTab, regularTab, -1) 21 | return out 22 | } 23 | 24 | // Predefine []byte variables to avoid runtime allocations. 25 | var ( 26 | escapedSlash = []byte(`\\`) 27 | regularSlash = []byte(`\`) 28 | escapedTab = []byte(`\t`) 29 | regularTab = []byte("\t") 30 | ) 31 | 32 | // parseTree parses tree information from the (uncompressed) raw data of the 33 | // tree object. 34 | func parseTree(t *Tree, data []byte) ([]*TreeEntry, error) { 35 | entries := make([]*TreeEntry, 0, 10) 36 | l := len(data) 37 | pos := 0 38 | for pos < l { 39 | entry := new(TreeEntry) 40 | entry.parent = t 41 | step := 6 42 | switch string(data[pos : pos+step]) { 43 | case "100644", "100664": 44 | entry.mode = EntryBlob 45 | entry.typ = ObjectBlob 46 | case "100755": 47 | entry.mode = EntryExec 48 | entry.typ = ObjectBlob 49 | case "120000": 50 | entry.mode = EntrySymlink 51 | entry.typ = ObjectBlob 52 | case "160000": 53 | entry.mode = EntryCommit 54 | entry.typ = ObjectCommit 55 | 56 | step = 8 57 | case "040000": 58 | entry.mode = EntryTree 59 | entry.typ = ObjectTree 60 | default: 61 | return nil, fmt.Errorf("unknown type: %v", string(data[pos:pos+step])) 62 | } 63 | pos += step + 6 // Skip string type of entry type. 64 | 65 | step = 40 66 | id, err := NewIDFromString(string(data[pos : pos+step])) 67 | if err != nil { 68 | return nil, err 69 | } 70 | entry.id = id 71 | pos += step + 1 // Skip half of SHA1. 72 | 73 | step = bytes.IndexByte(data[pos:], '\n') 74 | 75 | // In case entry name is surrounded by double quotes(it happens only in git-shell). 76 | if data[pos] == '"' { 77 | entry.name = string(UnescapeChars(data[pos+1 : pos+step-1])) 78 | } else { 79 | entry.name = string(data[pos : pos+step]) 80 | } 81 | 82 | pos += step + 1 83 | entries = append(entries, entry) 84 | } 85 | return entries, nil 86 | } 87 | 88 | // LsTreeOptions contains optional arguments for listing trees. 89 | // 90 | // Docs: https://git-scm.com/docs/git-ls-tree 91 | type LsTreeOptions struct { 92 | // The timeout duration before giving up for each shell command execution. The 93 | // default timeout duration will be used when not supplied. 94 | // 95 | // Deprecated: Use CommandOptions.Timeout instead. 96 | Timeout time.Duration 97 | // The additional options to be passed to the underlying git. 98 | CommandOptions 99 | } 100 | 101 | // LsTree returns the tree object in the repository by given tree ID. 102 | func (r *Repository) LsTree(treeID string, opts ...LsTreeOptions) (*Tree, error) { 103 | var opt LsTreeOptions 104 | if len(opts) > 0 { 105 | opt = opts[0] 106 | } 107 | 108 | cache, ok := r.cachedTrees.Get(treeID) 109 | if ok { 110 | log("Cached tree hit: %s", treeID) 111 | return cache.(*Tree), nil 112 | } 113 | 114 | var err error 115 | treeID, err = r.RevParse(treeID, RevParseOptions{Timeout: opt.Timeout}) //nolint 116 | if err != nil { 117 | return nil, err 118 | } 119 | t := &Tree{ 120 | id: MustIDFromString(treeID), 121 | repo: r, 122 | } 123 | 124 | stdout, err := NewCommand("ls-tree"). 125 | AddOptions(opt.CommandOptions). 126 | AddArgs(treeID). 127 | RunInDirWithTimeout(opt.Timeout, r.path) 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | t.entries, err = parseTree(t, stdout) 133 | if err != nil { 134 | return nil, err 135 | } 136 | 137 | r.cachedTrees.Set(treeID, t) 138 | return t, nil 139 | } 140 | -------------------------------------------------------------------------------- /repo_tree_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestRepository_LsTree(t *testing.T) { 14 | // Make sure it does not blow up 15 | tree, err := testrepo.LsTree("master", LsTreeOptions{}) 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | assert.NotNil(t, tree) 20 | 21 | // Tree ID for "gogs/" directory 22 | tree, err = testrepo.LsTree("fcf7087e732bfe3c25328248a9bf8c3ccd85bed4", LsTreeOptions{}) 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | assert.NotNil(t, tree) 27 | } 28 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "time" 9 | ) 10 | 11 | // UpdateServerInfoOptions contains optional arguments for updating auxiliary 12 | // info file on the server side. 13 | // 14 | // Docs: https://git-scm.com/docs/git-update-server-info 15 | type UpdateServerInfoOptions struct { 16 | // Indicates whether to overwrite the existing server info. 17 | Force bool 18 | // The timeout duration before giving up for each shell command execution. The 19 | // default timeout duration will be used when not supplied. 20 | Timeout time.Duration 21 | // The additional options to be passed to the underlying git. 22 | CommandOptions 23 | } 24 | 25 | // UpdateServerInfo updates the auxiliary info file on the server side for the 26 | // repository in given path. 27 | func UpdateServerInfo(path string, opts ...UpdateServerInfoOptions) error { 28 | var opt UpdateServerInfoOptions 29 | if len(opts) > 0 { 30 | opt = opts[0] 31 | } 32 | cmd := NewCommand("update-server-info").AddOptions(opt.CommandOptions) 33 | if opt.Force { 34 | cmd.AddArgs("--force") 35 | } 36 | _, err := cmd.RunInDirWithTimeout(opt.Timeout, path) 37 | return err 38 | } 39 | 40 | // ReceivePackOptions contains optional arguments for receiving the info pushed 41 | // to the repository. 42 | // 43 | // Docs: https://git-scm.com/docs/git-receive-pack 44 | type ReceivePackOptions struct { 45 | // Indicates whether to suppress the log output. 46 | Quiet bool 47 | // Indicates whether to generate the "info/refs" used by the "git http-backend". 48 | HTTPBackendInfoRefs bool 49 | // The timeout duration before giving up for each shell command execution. The 50 | // default timeout duration will be used when not supplied. 51 | Timeout time.Duration 52 | // The additional options to be passed to the underlying git. 53 | CommandOptions 54 | } 55 | 56 | // ReceivePack receives what is pushed into the repository in given path. 57 | func ReceivePack(path string, opts ...ReceivePackOptions) ([]byte, error) { 58 | var opt ReceivePackOptions 59 | if len(opts) > 0 { 60 | opt = opts[0] 61 | } 62 | cmd := NewCommand("receive-pack").AddOptions(opt.CommandOptions) 63 | if opt.Quiet { 64 | cmd.AddArgs("--quiet") 65 | } 66 | if opt.HTTPBackendInfoRefs { 67 | cmd.AddArgs("--http-backend-info-refs") 68 | } 69 | cmd.AddArgs(".") 70 | return cmd.RunInDirWithTimeout(opt.Timeout, path) 71 | } 72 | 73 | // UploadPackOptions contains optional arguments for sending the packfile to the 74 | // client. 75 | // 76 | // Docs: https://git-scm.com/docs/git-upload-pack 77 | type UploadPackOptions struct { 78 | // Indicates whether to quit after a single request/response exchange. 79 | StatelessRPC bool 80 | // Indicates whether to not try "/.git/" if "" is not a 81 | // Git directory. 82 | Strict bool 83 | // Indicates whether to generate the "info/refs" used by the "git http-backend". 84 | HTTPBackendInfoRefs bool 85 | // The timeout duration before giving up for each shell command execution. The 86 | // default timeout duration will be used when not supplied. 87 | Timeout time.Duration 88 | // The additional options to be passed to the underlying git. 89 | CommandOptions 90 | } 91 | 92 | // UploadPack sends the packfile to the client for the repository in given path. 93 | func UploadPack(path string, opts ...UploadPackOptions) ([]byte, error) { 94 | var opt UploadPackOptions 95 | if len(opts) > 0 { 96 | opt = opts[0] 97 | } 98 | cmd := NewCommand("upload-pack").AddOptions(opt.CommandOptions) 99 | if opt.StatelessRPC { 100 | cmd.AddArgs("--stateless-rpc") 101 | } 102 | if opt.Strict { 103 | cmd.AddArgs("--strict") 104 | } 105 | if opt.Timeout > 0 { 106 | cmd.AddArgs("--timeout", opt.Timeout.String()) 107 | } 108 | if opt.HTTPBackendInfoRefs { 109 | cmd.AddArgs("--http-backend-info-refs") 110 | } 111 | cmd.AddArgs(".") 112 | return cmd.RunInDirWithTimeout(opt.Timeout, path) 113 | } 114 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "os" 9 | "path/filepath" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestUpdateServerInfo(t *testing.T) { 17 | err := os.RemoveAll(filepath.Join(repoPath, "info")) 18 | require.NoError(t, err) 19 | err = UpdateServerInfo(repoPath, UpdateServerInfoOptions{Force: true}) 20 | require.NoError(t, err) 21 | assert.True(t, isFile(filepath.Join(repoPath, "info", "refs"))) 22 | } 23 | 24 | func TestReceivePack(t *testing.T) { 25 | got, err := ReceivePack(repoPath, ReceivePackOptions{HTTPBackendInfoRefs: true}) 26 | require.NoError(t, err) 27 | const contains = "report-status report-status-v2 delete-refs side-band-64k quiet atomic ofs-delta object-format=sha1 agent=git/" 28 | assert.Contains(t, string(got), contains) 29 | } 30 | 31 | func TestUploadPack(t *testing.T) { 32 | got, err := UploadPack(repoPath, 33 | UploadPackOptions{ 34 | StatelessRPC: true, 35 | Strict: true, 36 | HTTPBackendInfoRefs: true, 37 | }, 38 | ) 39 | require.NoError(t, err) 40 | const contains = "multi_ack thin-pack side-band side-band-64k ofs-delta shallow deepen-since deepen-not deepen-relative no-progress include-tag multi_ack_detailed no-done symref=HEAD:refs/heads/master object-format=sha1 agent=git/" 41 | assert.Contains(t, string(got), contains) 42 | } 43 | -------------------------------------------------------------------------------- /sha1.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "encoding/hex" 9 | "errors" 10 | "strings" 11 | "sync" 12 | ) 13 | 14 | // EmptyID is an ID with empty SHA-1 hash. 15 | const EmptyID = "0000000000000000000000000000000000000000" 16 | 17 | // SHA1 is the SHA-1 hash of a Git object. 18 | type SHA1 struct { 19 | bytes [20]byte 20 | 21 | str string 22 | strOnce sync.Once 23 | } 24 | 25 | // Equal returns true if s2 has the same SHA1 as s. It supports 26 | // 40-length-string, []byte, and SHA1. 27 | func (s *SHA1) Equal(s2 interface{}) bool { 28 | switch v := s2.(type) { 29 | case string: 30 | return v == s.String() 31 | case [20]byte: 32 | return v == s.bytes 33 | case *SHA1: 34 | return v.bytes == s.bytes 35 | } 36 | return false 37 | } 38 | 39 | // String returns string (hex) representation of the SHA1. 40 | func (s *SHA1) String() string { 41 | s.strOnce.Do(func() { 42 | result := make([]byte, 0, 40) 43 | hexvalues := []byte("0123456789abcdef") 44 | for i := 0; i < 20; i++ { 45 | result = append(result, hexvalues[s.bytes[i]>>4]) 46 | result = append(result, hexvalues[s.bytes[i]&0xf]) 47 | } 48 | s.str = string(result) 49 | }) 50 | return s.str 51 | } 52 | 53 | // MustID always returns a new SHA1 from a [20]byte array with no validation of 54 | // input. 55 | func MustID(b []byte) *SHA1 { 56 | var id SHA1 57 | for i := 0; i < 20; i++ { 58 | id.bytes[i] = b[i] 59 | } 60 | return &id 61 | } 62 | 63 | // NewID returns a new SHA1 from a [20]byte array. 64 | func NewID(b []byte) (*SHA1, error) { 65 | if len(b) != 20 { 66 | return nil, errors.New("length must be 20") 67 | } 68 | return MustID(b), nil 69 | } 70 | 71 | // MustIDFromString always returns a new sha from a ID with no validation of 72 | // input. 73 | func MustIDFromString(s string) *SHA1 { 74 | b, _ := hex.DecodeString(s) 75 | return MustID(b) 76 | } 77 | 78 | // NewIDFromString returns a new SHA1 from a ID string of length 40. 79 | func NewIDFromString(s string) (*SHA1, error) { 80 | s = strings.TrimSpace(s) 81 | if len(s) != 40 { 82 | return nil, errors.New("length must be 40") 83 | } 84 | b, err := hex.DecodeString(s) 85 | if err != nil { 86 | return nil, err 87 | } 88 | return NewID(b) 89 | } 90 | -------------------------------------------------------------------------------- /sha1_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "errors" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestSHA1_Equal(t *testing.T) { 15 | tests := []struct { 16 | s1 *SHA1 17 | s2 interface{} 18 | expVal bool 19 | }{ 20 | { 21 | s1: MustIDFromString("fcf7087e732bfe3c25328248a9bf8c3ccd85bed4"), 22 | s2: "fcf7087e732bfe3c25328248a9bf8c3ccd85bed4", 23 | expVal: true, 24 | }, { 25 | s1: MustIDFromString("fcf7087e732bfe3c25328248a9bf8c3ccd85bed4"), 26 | s2: EmptyID, 27 | expVal: false, 28 | }, 29 | 30 | { 31 | s1: MustIDFromString("fcf7087e732bfe3c25328248a9bf8c3ccd85bed4"), 32 | s2: MustIDFromString("fcf7087e732bfe3c25328248a9bf8c3ccd85bed4").bytes, 33 | expVal: true, 34 | }, { 35 | s1: MustIDFromString("fcf7087e732bfe3c25328248a9bf8c3ccd85bed4"), 36 | s2: MustIDFromString(EmptyID).bytes, 37 | expVal: false, 38 | }, 39 | 40 | { 41 | s1: MustIDFromString("fcf7087e732bfe3c25328248a9bf8c3ccd85bed4"), 42 | s2: MustIDFromString("fcf7087e732bfe3c25328248a9bf8c3ccd85bed4"), 43 | expVal: true, 44 | }, { 45 | s1: MustIDFromString("fcf7087e732bfe3c25328248a9bf8c3ccd85bed4"), 46 | s2: MustIDFromString(EmptyID), 47 | expVal: false, 48 | }, 49 | 50 | { 51 | s1: MustIDFromString("fcf7087e732bfe3c25328248a9bf8c3ccd85bed4"), 52 | s2: []byte(EmptyID), 53 | expVal: false, 54 | }, 55 | } 56 | for _, test := range tests { 57 | t.Run("", func(t *testing.T) { 58 | assert.Equal(t, test.expVal, test.s1.Equal(test.s2)) 59 | }) 60 | } 61 | } 62 | 63 | func TestNewID(t *testing.T) { 64 | sha, err := NewID([]byte("000000")) 65 | assert.Equal(t, errors.New("length must be 20"), err) 66 | assert.Nil(t, sha) 67 | } 68 | 69 | func TestNewIDFromString(t *testing.T) { 70 | sha, err := NewIDFromString("000000") 71 | assert.Equal(t, errors.New("length must be 40"), err) 72 | assert.Nil(t, sha) 73 | } 74 | -------------------------------------------------------------------------------- /signature.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "bytes" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | // Signature represents a author or committer. 14 | type Signature struct { 15 | // The name of the person. 16 | Name string 17 | // The email address. 18 | Email string 19 | // The time of the signature. 20 | When time.Time 21 | } 22 | 23 | // parseSignature parses signature information from the (uncompressed) commit 24 | // line, which looks like the following but without the "author " at the 25 | // beginning: 26 | // 27 | // author Patrick Gundlach 1378823654 +0200 28 | // author Patrick Gundlach Thu Apr 07 22:13:13 2005 +0200 29 | // 30 | // This method should only be used for parsing author and committer. 31 | func parseSignature(line []byte) (*Signature, error) { 32 | emailStart := bytes.IndexByte(line, '<') 33 | emailEnd := bytes.IndexByte(line, '>') 34 | sig := &Signature{ 35 | Name: string(line[:emailStart-1]), 36 | Email: string(line[emailStart+1 : emailEnd]), 37 | } 38 | 39 | // Check the date format 40 | firstChar := line[emailEnd+2] 41 | if firstChar >= 48 && firstChar <= 57 { // ASCII code for 0-9 42 | timestop := bytes.IndexByte(line[emailEnd+2:], ' ') 43 | timestamp := line[emailEnd+2 : emailEnd+2+timestop] 44 | seconds, err := strconv.ParseInt(string(timestamp), 10, 64) 45 | if err != nil { 46 | return nil, err 47 | } 48 | sig.When = time.Unix(seconds, 0) 49 | return sig, nil 50 | } 51 | 52 | var err error 53 | sig.When, err = time.Parse("Mon Jan _2 15:04:05 2006 -0700", string(line[emailEnd+2:])) 54 | if err != nil { 55 | return nil, err 56 | } 57 | return sig, nil 58 | } 59 | -------------------------------------------------------------------------------- /signature_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func Test_parseSignature(t *testing.T) { 15 | tests := []struct { 16 | line string 17 | expSig *Signature 18 | }{ 19 | { 20 | line: "Patrick Gundlach 1378823654 +0200", 21 | expSig: &Signature{ 22 | Name: "Patrick Gundlach", 23 | Email: "gundlach@speedata.de", 24 | When: time.Unix(1378823654, 0), 25 | }, 26 | }, { 27 | line: "Patrick Gundlach Tue Sep 10 16:34:14 2013 +0200", 28 | expSig: &Signature{ 29 | Name: "Patrick Gundlach", 30 | Email: "gundlach@speedata.de", 31 | When: time.Unix(1378823654, 0), 32 | }, 33 | }, 34 | } 35 | for _, test := range tests { 36 | t.Run("", func(t *testing.T) { 37 | sig, err := parseSignature([]byte(test.line)) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | 42 | assert.Equal(t, test.expSig.Name, sig.Name) 43 | assert.Equal(t, test.expSig.Email, sig.Email) 44 | assert.Equal(t, test.expSig.When.Unix(), sig.When.Unix()) 45 | }) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tag.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | // Tag contains information of a Git tag. 8 | type Tag struct { 9 | typ ObjectType 10 | id *SHA1 11 | commitID *SHA1 // The ID of the underlying commit 12 | refspec string 13 | tagger *Signature 14 | message string 15 | 16 | repo *Repository 17 | } 18 | 19 | // Type returns the type of the tag. 20 | func (t *Tag) Type() ObjectType { 21 | return t.typ 22 | } 23 | 24 | // ID returns the SHA-1 hash of the tag. 25 | func (t *Tag) ID() *SHA1 { 26 | return t.id 27 | } 28 | 29 | // CommitID returns the commit ID of the tag. 30 | func (t *Tag) CommitID() *SHA1 { 31 | return t.commitID 32 | } 33 | 34 | // Refspec returns the refspec of the tag. 35 | func (t *Tag) Refspec() string { 36 | return t.refspec 37 | } 38 | 39 | // Tagger returns the tagger of the tag. 40 | func (t *Tag) Tagger() *Signature { 41 | return t.tagger 42 | } 43 | 44 | // Message returns the message of the tag. 45 | func (t *Tag) Message() string { 46 | return t.message 47 | } 48 | 49 | // Commit returns the underlying commit of the tag. 50 | func (t *Tag) Commit(opts ...CatFileCommitOptions) (*Commit, error) { 51 | return t.repo.CatFileCommit(t.commitID.String(), opts...) 52 | } 53 | -------------------------------------------------------------------------------- /tag_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestTag(t *testing.T) { 14 | tag, err := testrepo.Tag("v1.1.0") 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | 19 | assert.Equal(t, ObjectTag, tag.Type()) 20 | assert.Equal(t, "b39c8508bbc4b00ad2e24d358012ea123bcafd8d", tag.ID().String()) 21 | assert.Equal(t, "0eedd79eba4394bbef888c804e899731644367fe", tag.CommitID().String()) 22 | assert.Equal(t, "refs/tags/v1.1.0", tag.Refspec()) 23 | 24 | t.Run("Tagger", func(t *testing.T) { 25 | assert.Equal(t, "Joe Chen", tag.Tagger().Name) 26 | assert.Equal(t, "joe@sourcegraph.com", tag.Tagger().Email) 27 | assert.Equal(t, int64(1581602099), tag.Tagger().When.Unix()) 28 | }) 29 | 30 | assert.Equal(t, "The version 1.1.0\n", tag.Message()) 31 | } 32 | 33 | func TestTag_Commit(t *testing.T) { 34 | tag, err := testrepo.Tag("v1.1.0") 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | 39 | c, err := tag.Commit() 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | assert.Equal(t, "0eedd79eba4394bbef888c804e899731644367fe", c.ID.String()) 45 | } 46 | -------------------------------------------------------------------------------- /tree.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "strings" 9 | "sync" 10 | ) 11 | 12 | // Tree represents a flat directory listing in Git. 13 | type Tree struct { 14 | id *SHA1 15 | parent *Tree 16 | 17 | repo *Repository 18 | 19 | entries Entries 20 | entriesOnce sync.Once 21 | entriesErr error 22 | } 23 | 24 | // Subtree returns a subtree by given subpath of the tree. 25 | func (t *Tree) Subtree(subpath string, opts ...LsTreeOptions) (*Tree, error) { 26 | if len(subpath) == 0 { 27 | return t, nil 28 | } 29 | 30 | paths := strings.Split(subpath, "/") 31 | var ( 32 | err error 33 | g = t 34 | p = t 35 | e *TreeEntry 36 | ) 37 | for _, name := range paths { 38 | e, err = p.TreeEntry(name, opts...) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | g = &Tree{ 44 | id: e.id, 45 | parent: p, 46 | repo: t.repo, 47 | } 48 | p = g 49 | } 50 | return g, nil 51 | } 52 | 53 | // Entries returns all entries of the tree. 54 | func (t *Tree) Entries(opts ...LsTreeOptions) (Entries, error) { 55 | t.entriesOnce.Do(func() { 56 | if t.entries != nil { 57 | return 58 | } 59 | 60 | var tt *Tree 61 | tt, t.entriesErr = t.repo.LsTree(t.id.String(), opts...) 62 | if t.entriesErr != nil { 63 | return 64 | } 65 | t.entries = tt.entries 66 | }) 67 | 68 | return t.entries, t.entriesErr 69 | } 70 | -------------------------------------------------------------------------------- /tree_blob.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "path" 9 | "strings" 10 | ) 11 | 12 | // TreeEntry returns the TreeEntry by given subpath of the tree. 13 | func (t *Tree) TreeEntry(subpath string, opts ...LsTreeOptions) (*TreeEntry, error) { 14 | if len(subpath) == 0 { 15 | return &TreeEntry{ 16 | id: t.id, 17 | typ: ObjectTree, 18 | mode: EntryTree, 19 | }, nil 20 | } 21 | 22 | subpath = path.Clean(subpath) 23 | paths := strings.Split(subpath, "/") 24 | var err error 25 | tree := t 26 | for i, name := range paths { 27 | // Reached end of the loop 28 | if i == len(paths)-1 { 29 | entries, err := tree.Entries(opts...) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | for _, v := range entries { 35 | if v.name == name { 36 | return v, nil 37 | } 38 | } 39 | } else { 40 | tree, err = tree.Subtree(name, opts...) 41 | if err != nil { 42 | return nil, err 43 | } 44 | } 45 | } 46 | return nil, ErrRevisionNotExist 47 | } 48 | 49 | // Blob returns the blob object by given subpath of the tree. 50 | func (t *Tree) Blob(subpath string, opts ...LsTreeOptions) (*Blob, error) { 51 | e, err := t.TreeEntry(subpath, opts...) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | if e.IsBlob() || e.IsExec() { 57 | return e.Blob(), nil 58 | } 59 | 60 | return nil, ErrNotBlob 61 | } 62 | 63 | // BlobByIndex returns blob object by given index. 64 | func (t *Tree) BlobByIndex(index string) (*Blob, error) { 65 | typ, err := t.repo.CatFileType(index) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | if typ != ObjectBlob { 71 | return nil, ErrNotBlob 72 | } 73 | 74 | id, err := t.repo.RevParse(index) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | return &Blob{ 80 | TreeEntry: &TreeEntry{ 81 | mode: EntryBlob, 82 | typ: ObjectBlob, 83 | id: MustIDFromString(id), 84 | parent: t, 85 | }, 86 | }, nil 87 | } 88 | -------------------------------------------------------------------------------- /tree_blob_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestTree_TreeEntry(t *testing.T) { 14 | tree, err := testrepo.LsTree("master") 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | 19 | e, err := tree.TreeEntry("") 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | assert.Equal(t, tree.id, e.ID()) 25 | assert.Equal(t, ObjectTree, e.Type()) 26 | assert.True(t, e.IsTree()) 27 | } 28 | 29 | func TestTree_Blob(t *testing.T) { 30 | tree, err := testrepo.LsTree("d58e3ef9f123eea6857161c79275ee22b228f659") 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | 35 | t.Run("not a blob", func(t *testing.T) { 36 | _, err := tree.Blob("src") 37 | assert.Equal(t, ErrNotBlob, err) 38 | }) 39 | 40 | t.Run("get a blob", func(t *testing.T) { 41 | b, err := tree.Blob("README.txt") 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | assert.True(t, b.IsBlob()) 47 | }) 48 | 49 | t.Run("get an executable as blob", func(t *testing.T) { 50 | b, err := tree.Blob("run.sh") 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | 55 | assert.True(t, b.IsExec()) 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /tree_entry.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "fmt" 9 | "path" 10 | "runtime" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | "sync" 15 | "sync/atomic" 16 | "time" 17 | ) 18 | 19 | // EntryMode is the unix file mode of a tree entry. 20 | type EntryMode int 21 | 22 | // There are only a few file modes in Git. They look like unix file modes, but 23 | // they can only be one of these. 24 | const ( 25 | EntryTree EntryMode = 0040000 26 | EntryBlob EntryMode = 0100644 27 | EntryExec EntryMode = 0100755 28 | EntrySymlink EntryMode = 0120000 29 | EntryCommit EntryMode = 0160000 30 | ) 31 | 32 | type TreeEntry struct { 33 | mode EntryMode 34 | typ ObjectType 35 | id *SHA1 36 | name string 37 | 38 | parent *Tree 39 | 40 | size int64 41 | sizeOnce sync.Once 42 | } 43 | 44 | // Mode returns the entry mode if the tree entry. 45 | func (e *TreeEntry) Mode() EntryMode { 46 | return e.mode 47 | } 48 | 49 | // IsTree returns tree if the entry itself is another tree (i.e. a directory). 50 | func (e *TreeEntry) IsTree() bool { 51 | return e.mode == EntryTree 52 | } 53 | 54 | // IsBlob returns true if the entry is a blob. 55 | func (e *TreeEntry) IsBlob() bool { 56 | return e.mode == EntryBlob 57 | } 58 | 59 | // IsExec returns tree if the entry is an executable. 60 | func (e *TreeEntry) IsExec() bool { 61 | return e.mode == EntryExec 62 | } 63 | 64 | // IsSymlink returns true if the entry is a symbolic link. 65 | func (e *TreeEntry) IsSymlink() bool { 66 | return e.mode == EntrySymlink 67 | } 68 | 69 | // IsCommit returns true if the entry is a commit (i.e. a submodule). 70 | func (e *TreeEntry) IsCommit() bool { 71 | return e.mode == EntryCommit 72 | } 73 | 74 | // Type returns the object type of the entry. 75 | func (e *TreeEntry) Type() ObjectType { 76 | return e.typ 77 | } 78 | 79 | // ID returns the SHA-1 hash of the entry. 80 | func (e *TreeEntry) ID() *SHA1 { 81 | return e.id 82 | } 83 | 84 | // Name returns name of the entry. 85 | func (e *TreeEntry) Name() string { 86 | return e.name 87 | } 88 | 89 | // Size returns the size of thr entry. 90 | func (e *TreeEntry) Size() int64 { 91 | e.sizeOnce.Do(func() { 92 | if e.IsTree() { 93 | return 94 | } 95 | 96 | stdout, err := NewCommand("cat-file", "-s", e.id.String()).RunInDir(e.parent.repo.path) 97 | if err != nil { 98 | return 99 | } 100 | e.size, _ = strconv.ParseInt(strings.TrimSpace(string(stdout)), 10, 64) 101 | }) 102 | 103 | return e.size 104 | } 105 | 106 | // Blob returns a blob object from the entry. 107 | func (e *TreeEntry) Blob() *Blob { 108 | return &Blob{ 109 | TreeEntry: e, 110 | } 111 | } 112 | 113 | // Entries is a sortable list of tree entries. 114 | type Entries []*TreeEntry 115 | 116 | var sorters = []func(t1, t2 *TreeEntry) bool{ 117 | func(t1, t2 *TreeEntry) bool { 118 | return (t1.IsTree() || t1.IsCommit()) && !t2.IsTree() && !t2.IsCommit() 119 | }, 120 | func(t1, t2 *TreeEntry) bool { 121 | return t1.name < t2.name 122 | }, 123 | } 124 | 125 | func (es Entries) Len() int { return len(es) } 126 | func (es Entries) Swap(i, j int) { es[i], es[j] = es[j], es[i] } 127 | func (es Entries) Less(i, j int) bool { 128 | t1, t2 := es[i], es[j] 129 | var k int 130 | for k = 0; k < len(sorters)-1; k++ { 131 | sorter := sorters[k] 132 | switch { 133 | case sorter(t1, t2): 134 | return true 135 | case sorter(t2, t1): 136 | return false 137 | } 138 | } 139 | return sorters[k](t1, t2) 140 | } 141 | 142 | func (es Entries) Sort() { 143 | sort.Sort(es) 144 | } 145 | 146 | // EntryCommitInfo contains a tree entry with its commit information. 147 | type EntryCommitInfo struct { 148 | Entry *TreeEntry 149 | Index int 150 | Commit *Commit 151 | Submodule *Submodule 152 | } 153 | 154 | // CommitsInfoOptions contains optional arguments for getting commits 155 | // information. 156 | type CommitsInfoOptions struct { 157 | // The relative path of the repository. 158 | Path string 159 | // The maximum number of goroutines to be used for getting commits information. 160 | // When not set (i.e. <=0), runtime.GOMAXPROCS is used to determine the value. 161 | MaxConcurrency int 162 | // The timeout duration before giving up for each shell command execution. 163 | // The default timeout duration will be used when not supplied. 164 | Timeout time.Duration 165 | } 166 | 167 | var defaultConcurrency = runtime.GOMAXPROCS(0) 168 | 169 | // CommitsInfo returns a list of commit information for these tree entries in 170 | // the state of given commit and subpath. It takes advantages of concurrency to 171 | // speed up the process. The returned list has the same number of items as tree 172 | // entries, so the caller can access them via slice indices. 173 | func (es Entries) CommitsInfo(commit *Commit, opts ...CommitsInfoOptions) ([]*EntryCommitInfo, error) { 174 | if len(es) == 0 { 175 | return []*EntryCommitInfo{}, nil 176 | } 177 | 178 | var opt CommitsInfoOptions 179 | if len(opts) > 0 { 180 | opt = opts[0] 181 | } 182 | 183 | if opt.MaxConcurrency <= 0 { 184 | opt.MaxConcurrency = defaultConcurrency 185 | } 186 | 187 | // Length of bucket determines how many goroutines (subprocesses) can run at the same time. 188 | bucket := make(chan struct{}, opt.MaxConcurrency) 189 | results := make(chan *EntryCommitInfo, len(es)) 190 | errs := make(chan error, 1) 191 | 192 | var errored int64 193 | hasErrored := func() bool { 194 | return atomic.LoadInt64(&errored) != 0 195 | } 196 | // Only count for the first error, discard the rest 197 | setError := func(err error) { 198 | if !atomic.CompareAndSwapInt64(&errored, 0, 1) { 199 | return 200 | } 201 | errs <- err 202 | } 203 | 204 | var wg sync.WaitGroup 205 | wg.Add(len(es)) 206 | go func() { 207 | for i, e := range es { 208 | // Shrink down the counter and exit when there is an error 209 | if hasErrored() { 210 | wg.Add(i - len(es)) 211 | return 212 | } 213 | 214 | // Block until there is an empty slot to control the maximum concurrency 215 | bucket <- struct{}{} 216 | 217 | go func(e *TreeEntry, i int) { 218 | defer func() { 219 | wg.Done() 220 | <-bucket 221 | }() 222 | 223 | // Avoid expensive operations if has errored 224 | if hasErrored() { 225 | return 226 | } 227 | 228 | info := &EntryCommitInfo{ 229 | Entry: e, 230 | Index: i, 231 | } 232 | epath := path.Join(opt.Path, e.Name()) 233 | 234 | var err error 235 | info.Commit, err = commit.CommitByPath(CommitByRevisionOptions{ 236 | Path: epath, 237 | Timeout: opt.Timeout, 238 | }) 239 | if err != nil { 240 | setError(fmt.Errorf("get commit by path %q: %v", epath, err)) 241 | return 242 | } 243 | 244 | // Get extra information for submodules 245 | if e.IsCommit() { 246 | // Be tolerant to implicit submodules 247 | info.Submodule, err = commit.Submodule(epath) 248 | if err != nil { 249 | info.Submodule = &Submodule{Name: epath} 250 | } 251 | } 252 | 253 | results <- info 254 | }(e, i) 255 | } 256 | }() 257 | 258 | wg.Wait() 259 | if hasErrored() { 260 | return nil, <-errs 261 | } 262 | 263 | close(results) 264 | 265 | commitsInfo := make([]*EntryCommitInfo, len(es)) 266 | for info := range results { 267 | commitsInfo[info.Index] = info 268 | } 269 | return commitsInfo, nil 270 | } 271 | -------------------------------------------------------------------------------- /tree_entry_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestTreeEntry(t *testing.T) { 14 | id := MustIDFromString("0eedd79eba4394bbef888c804e899731644367fe") 15 | e := &TreeEntry{ 16 | mode: EntrySymlink, 17 | typ: ObjectTree, 18 | id: id, 19 | name: "go.mod", 20 | } 21 | 22 | assert.False(t, e.IsTree()) 23 | assert.False(t, e.IsBlob()) 24 | assert.False(t, e.IsExec()) 25 | assert.True(t, e.IsSymlink()) 26 | assert.False(t, e.IsCommit()) 27 | 28 | assert.Equal(t, ObjectTree, e.Type()) 29 | assert.Equal(t, e.id, e.ID()) 30 | assert.Equal(t, "go.mod", e.Name()) 31 | } 32 | 33 | func TestEntries_Sort(t *testing.T) { 34 | tree, err := testrepo.LsTree("0eedd79eba4394bbef888c804e899731644367fe") 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | 39 | es, err := tree.Entries() 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | es.Sort() 45 | 46 | expEntries := []*TreeEntry{ 47 | { 48 | mode: EntryTree, 49 | typ: ObjectTree, 50 | id: MustIDFromString("fcf7087e732bfe3c25328248a9bf8c3ccd85bed4"), 51 | name: "gogs", 52 | }, { 53 | mode: EntryTree, 54 | typ: ObjectTree, 55 | id: MustIDFromString("a41a5a6cfd2d5ec3c0c1101e7cc05c9dedc3e11d"), 56 | name: "img", 57 | }, { 58 | mode: EntryTree, 59 | typ: ObjectTree, 60 | id: MustIDFromString("aaa0af6b82db99c660b169962524e2201ac7079c"), 61 | name: "resources", 62 | }, { 63 | mode: EntryTree, 64 | typ: ObjectTree, 65 | id: MustIDFromString("007cb92318c7bd3b56908ea8c2e54370245562f8"), 66 | name: "src", 67 | }, { 68 | mode: EntryBlob, 69 | typ: ObjectBlob, 70 | id: MustIDFromString("021a721a61a1de65865542c405796d1eb985f784"), 71 | name: ".DS_Store", 72 | }, { 73 | mode: EntryBlob, 74 | typ: ObjectBlob, 75 | id: MustIDFromString("412eeda78dc9de1186c2e0e1526764af82ab3431"), 76 | name: ".gitattributes", 77 | }, { 78 | mode: EntryBlob, 79 | typ: ObjectBlob, 80 | id: MustIDFromString("7c820833a9ad5fbfc96efd533d55f5edc65dc977"), 81 | name: ".gitignore", 82 | }, { 83 | mode: EntryBlob, 84 | typ: ObjectBlob, 85 | id: MustIDFromString("6abde17f49a6d43df40366e57d8964fee0dfda11"), 86 | name: ".gitmodules", 87 | }, { 88 | mode: EntryBlob, 89 | typ: ObjectBlob, 90 | id: MustIDFromString("17eccd68b7cafa718d53c8b4db666194646e2bd9"), 91 | name: ".travis.yml", 92 | }, { 93 | mode: EntryBlob, 94 | typ: ObjectBlob, 95 | id: MustIDFromString("adfd6da3c0a3fb038393144becbf37f14f780087"), 96 | name: "README.txt", 97 | }, { 98 | mode: EntryBlob, 99 | typ: ObjectBlob, 100 | id: MustIDFromString("6058be211566308428ca6dcab3f08cf270cd9568"), 101 | name: "build.gradle", 102 | }, { 103 | mode: EntryBlob, 104 | typ: ObjectBlob, 105 | id: MustIDFromString("99975710477a65b89233b2d12bf60f7c0ffc1f5c"), 106 | name: "pom.xml", 107 | }, { 108 | mode: EntryExec, 109 | typ: ObjectBlob, 110 | id: MustIDFromString("fb4bd4ec9220ed4fe0d9526d1b77147490ce8842"), 111 | name: "run.sh", 112 | }, 113 | } 114 | for i := range expEntries { 115 | assert.Equal(t, expEntries[i].Mode(), es[i].Mode(), "idx: %d", i) 116 | assert.Equal(t, expEntries[i].Type(), es[i].Type(), "idx: %d", i) 117 | assert.Equal(t, expEntries[i].ID().String(), es[i].ID().String(), "idx: %d", i) 118 | assert.Equal(t, expEntries[i].Name(), es[i].Name(), "idx: %d", i) 119 | } 120 | } 121 | 122 | func TestEntries_CommitsInfo(t *testing.T) { 123 | tree, err := testrepo.LsTree("cfc3b2993f74726356887a5ec093de50486dc617") 124 | if err != nil { 125 | t.Fatal(err) 126 | } 127 | 128 | c, err := testrepo.CatFileCommit(tree.id.String()) 129 | if err != nil { 130 | t.Fatal(err) 131 | } 132 | 133 | t.Run("general directory", func(t *testing.T) { 134 | es, err := tree.Entries() 135 | if err != nil { 136 | t.Fatal(err) 137 | } 138 | 139 | infos, err := es.CommitsInfo(c) 140 | if err != nil { 141 | t.Fatal(err) 142 | } 143 | 144 | expInfos := []*EntryCommitInfo{ 145 | { 146 | Entry: &TreeEntry{ 147 | name: ".DS_Store", 148 | }, 149 | Commit: &Commit{ 150 | ID: MustIDFromString("4eaa8d4b05e731e950e2eaf9e8b92f522303ab41"), 151 | }, 152 | }, { 153 | Entry: &TreeEntry{ 154 | name: ".gitattributes", 155 | }, 156 | Commit: &Commit{ 157 | ID: MustIDFromString("bf7a9a5ee025edee0e610bd7ba23c0704b53c6db"), 158 | }, 159 | }, { 160 | Entry: &TreeEntry{ 161 | name: ".gitignore", 162 | }, 163 | Commit: &Commit{ 164 | ID: MustIDFromString("d2280d000c84f1e595e4dec435ae6c1e6c245367"), 165 | }, 166 | }, { 167 | Entry: &TreeEntry{ 168 | name: ".gitmodules", 169 | }, 170 | Commit: &Commit{ 171 | ID: MustIDFromString("4e59b72440188e7c2578299fc28ea425fbe9aece"), 172 | }, 173 | }, { 174 | Entry: &TreeEntry{ 175 | name: ".travis.yml", 176 | }, 177 | Commit: &Commit{ 178 | ID: MustIDFromString("9805760644754c38d10a9f1522a54a4bdc00fa8a"), 179 | }, 180 | }, { 181 | Entry: &TreeEntry{ 182 | name: "README.txt", 183 | }, 184 | Commit: &Commit{ 185 | ID: MustIDFromString("a13dba1e469944772490909daa58c53ac8fa4b0d"), 186 | }, 187 | }, { 188 | Entry: &TreeEntry{ 189 | name: "build.gradle", 190 | }, 191 | Commit: &Commit{ 192 | ID: MustIDFromString("c59479302142d79e46f84d11438a41b39ba51a1f"), 193 | }, 194 | }, { 195 | Entry: &TreeEntry{ 196 | name: "gogs", 197 | }, 198 | Commit: &Commit{ 199 | ID: MustIDFromString("4e59b72440188e7c2578299fc28ea425fbe9aece"), 200 | }, 201 | }, { 202 | Entry: &TreeEntry{ 203 | name: "img", 204 | }, 205 | Commit: &Commit{ 206 | ID: MustIDFromString("4eaa8d4b05e731e950e2eaf9e8b92f522303ab41"), 207 | }, 208 | }, { 209 | Entry: &TreeEntry{ 210 | name: "pom.xml", 211 | }, 212 | Commit: &Commit{ 213 | ID: MustIDFromString("ef7bebf8bdb1919d947afe46ab4b2fb4278039b3"), 214 | }, 215 | }, { 216 | Entry: &TreeEntry{ 217 | name: "resources", 218 | }, 219 | Commit: &Commit{ 220 | ID: MustIDFromString("755fd577edcfd9209d0ac072eed3b022cbe4d39b"), 221 | }, 222 | }, { 223 | Entry: &TreeEntry{ 224 | name: "run.sh", 225 | }, 226 | Commit: &Commit{ 227 | ID: MustIDFromString("0eedd79eba4394bbef888c804e899731644367fe"), 228 | }, 229 | }, { 230 | Entry: &TreeEntry{ 231 | name: "sameSHAs", 232 | }, 233 | Commit: &Commit{ 234 | ID: MustIDFromString("cfc3b2993f74726356887a5ec093de50486dc617"), 235 | }, 236 | }, { 237 | Entry: &TreeEntry{ 238 | name: "src", 239 | }, 240 | Commit: &Commit{ 241 | ID: MustIDFromString("ebbbf773431ba07510251bb03f9525c7bab2b13a"), 242 | }, 243 | }, 244 | } 245 | for i := range expInfos { 246 | assert.Equal(t, expInfos[i].Entry.Name(), infos[i].Entry.Name(), "idx: %d", i) 247 | assert.Equal(t, expInfos[i].Commit.ID.String(), infos[i].Commit.ID.String(), "idx: %d", i) 248 | } 249 | }) 250 | 251 | t.Run("directory with submodule", func(t *testing.T) { 252 | subtree, err := tree.Subtree("gogs") 253 | if err != nil { 254 | t.Fatal(err) 255 | } 256 | 257 | es, err := subtree.Entries() 258 | if err != nil { 259 | t.Fatal(err) 260 | } 261 | 262 | infos, err := es.CommitsInfo(c, CommitsInfoOptions{ 263 | Path: "gogs", 264 | }) 265 | if err != nil { 266 | t.Fatal(err) 267 | } 268 | 269 | expInfos := []*EntryCommitInfo{ 270 | { 271 | Entry: &TreeEntry{ 272 | name: "docs-api", 273 | }, 274 | Commit: &Commit{ 275 | ID: MustIDFromString("4e59b72440188e7c2578299fc28ea425fbe9aece"), 276 | }, 277 | }, 278 | } 279 | for i := range expInfos { 280 | assert.Equal(t, expInfos[i].Entry.Name(), infos[i].Entry.Name(), "idx: %d", i) 281 | assert.Equal(t, expInfos[i].Commit.ID.String(), infos[i].Commit.ID.String(), "idx: %d", i) 282 | } 283 | }) 284 | 285 | t.Run("direcotry with files have same SHA", func(t *testing.T) { 286 | subtree, err := tree.Subtree("sameSHAs") 287 | if err != nil { 288 | t.Fatal(err) 289 | } 290 | 291 | es, err := subtree.Entries() 292 | if err != nil { 293 | t.Fatal(err) 294 | } 295 | 296 | infos, err := es.CommitsInfo(c, CommitsInfoOptions{ 297 | Path: "sameSHAs", 298 | }) 299 | if err != nil { 300 | t.Fatal(err) 301 | } 302 | 303 | expInfos := []*EntryCommitInfo{ 304 | { 305 | Entry: &TreeEntry{ 306 | name: "file1.txt", 307 | }, 308 | Commit: &Commit{ 309 | ID: MustIDFromString("cfc3b2993f74726356887a5ec093de50486dc617"), 310 | }, 311 | }, { 312 | Entry: &TreeEntry{ 313 | name: "file2.txt", 314 | }, 315 | Commit: &Commit{ 316 | ID: MustIDFromString("cfc3b2993f74726356887a5ec093de50486dc617"), 317 | }, 318 | }, 319 | } 320 | for i := range expInfos { 321 | assert.Equal(t, expInfos[i].Entry.Name(), infos[i].Entry.Name(), "idx: %d", i) 322 | assert.Equal(t, expInfos[i].Commit.ID.String(), infos[i].Commit.ID.String(), "idx: %d", i) 323 | } 324 | }) 325 | } 326 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Gogs Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package git 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "strings" 11 | "sync" 12 | ) 13 | 14 | // objectCache provides thread-safe cache operations. TODO(@unknwon): Use 15 | // sync.Map once requires Go 1.13. 16 | type objectCache struct { 17 | lock sync.RWMutex 18 | cache map[string]interface{} 19 | } 20 | 21 | func newObjectCache() *objectCache { 22 | return &objectCache{ 23 | cache: make(map[string]interface{}), 24 | } 25 | } 26 | 27 | func (oc *objectCache) Set(id string, obj interface{}) { 28 | oc.lock.Lock() 29 | defer oc.lock.Unlock() 30 | 31 | oc.cache[id] = obj 32 | } 33 | 34 | func (oc *objectCache) Get(id string) (interface{}, bool) { 35 | oc.lock.RLock() 36 | defer oc.lock.RUnlock() 37 | 38 | obj, has := oc.cache[id] 39 | return obj, has 40 | } 41 | 42 | // isDir returns true if given path is a directory, or returns false when it's a 43 | // file or does not exist. 44 | func isDir(dir string) bool { 45 | f, e := os.Stat(dir) 46 | if e != nil { 47 | return false 48 | } 49 | return f.IsDir() 50 | } 51 | 52 | // isFile returns true if given path is a file, or returns false when it's a 53 | // directory or does not exist. 54 | func isFile(filePath string) bool { 55 | f, e := os.Stat(filePath) 56 | if e != nil { 57 | return false 58 | } 59 | return !f.IsDir() 60 | } 61 | 62 | // isExist checks whether a file or directory exists. It returns false when the 63 | // file or directory does not exist. 64 | func isExist(path string) bool { 65 | _, err := os.Stat(path) 66 | return err == nil || os.IsExist(err) 67 | } 68 | 69 | func concatenateError(err error, stderr string) error { 70 | if len(stderr) == 0 { 71 | return err 72 | } 73 | return fmt.Errorf("%v - %s", err, stderr) 74 | } 75 | 76 | // bytesToStrings splits given bytes into strings by line separator ("\n"). It 77 | // returns empty slice if the given bytes only contains line separators. 78 | func bytesToStrings(in []byte) []string { 79 | s := strings.TrimRight(string(in), "\n") 80 | if s == "" { // empty (not {""}, len=1) 81 | return []string{} 82 | } 83 | return strings.Split(s, "\n") 84 | } 85 | --------------------------------------------------------------------------------