diff options
Diffstat (limited to 'modules')
110 files changed, 4655 insertions, 17 deletions
diff --git a/modules/base/tool.go b/modules/base/tool.go index b069e5faee..359cf87d77 100644 --- a/modules/base/tool.go +++ b/modules/base/tool.go @@ -23,10 +23,11 @@ import ( "unicode" "unicode/utf8" - "code.gitea.io/git" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" + "github.com/Unknwon/com" "github.com/Unknwon/i18n" "github.com/gogits/chardet" diff --git a/modules/context/api.go b/modules/context/api.go index 7ec4d9036c..04a472b382 100644 --- a/modules/context/api.go +++ b/modules/context/api.go @@ -13,11 +13,12 @@ import ( "github.com/go-macaron/csrf" - "code.gitea.io/git" "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "github.com/Unknwon/paginater" macaron "gopkg.in/macaron.v1" ) diff --git a/modules/context/repo.go b/modules/context/repo.go index e0ee802f7d..20a3d19428 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -12,9 +12,9 @@ import ( "path" "strings" - "code.gitea.io/git" "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" diff --git a/modules/git/README.md b/modules/git/README.md new file mode 100644 index 0000000000..4418c1b891 --- /dev/null +++ b/modules/git/README.md @@ -0,0 +1,3 @@ +# Git Module + +This module is merged from https://github.com/go-gitea/git which is a Go module to access Git through shell commands. Now it's a part of gitea's main repository for easier pull request. diff --git a/modules/git/blob.go b/modules/git/blob.go new file mode 100644 index 0000000000..a6e392eeb5 --- /dev/null +++ b/modules/git/blob.go @@ -0,0 +1,73 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" +) + +// Blob represents a Git object. +type Blob struct { + repo *Repository + *TreeEntry +} + +// Data gets content of blob all at once and wrap it as io.Reader. +// This can be very slow and memory consuming for huge content. +func (b *Blob) Data() (io.Reader, error) { + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + + // Preallocate memory to save ~50% memory usage on big files. + stdout.Grow(int(b.Size() + 2048)) + + if err := b.DataPipeline(stdout, stderr); err != nil { + return nil, concatenateError(err, stderr.String()) + } + return stdout, nil +} + +// DataPipeline gets content of blob and write the result or error to stdout or stderr +func (b *Blob) DataPipeline(stdout, stderr io.Writer) error { + return NewCommand("show", b.ID.String()).RunInDirPipeline(b.repo.Path, stdout, stderr) +} + +type cmdReadCloser struct { + cmd *exec.Cmd + stdout io.Reader +} + +func (c cmdReadCloser) Read(p []byte) (int, error) { + return c.stdout.Read(p) +} + +func (c cmdReadCloser) Close() error { + io.Copy(ioutil.Discard, c.stdout) + return c.cmd.Wait() +} + +// DataAsync gets a ReadCloser for the contents of a blob without reading it all. +// Calling the Close function on the result will discard all unread output. +func (b *Blob) DataAsync() (io.ReadCloser, error) { + cmd := exec.Command("git", "show", b.ID.String()) + cmd.Dir = b.repo.Path + cmd.Stderr = os.Stderr + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("StdoutPipe: %v", err) + } + + if err = cmd.Start(); err != nil { + return nil, fmt.Errorf("Start: %v", err) + } + + return cmdReadCloser{stdout: stdout, cmd: cmd}, nil +} diff --git a/modules/git/blob_test.go b/modules/git/blob_test.go new file mode 100644 index 0000000000..39516c422c --- /dev/null +++ b/modules/git/blob_test.go @@ -0,0 +1,80 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "bytes" + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var repoSelf = &Repository{ + Path: "./", +} + +var testBlob = &Blob{ + repo: repoSelf, + TreeEntry: &TreeEntry{ + ID: MustIDFromString("a8d4b49dd073a4a38a7e58385eeff7cc52568697"), + ptree: &Tree{ + repo: repoSelf, + }, + }, +} + +func TestBlob_Data(t *testing.T) { + output := `Copyright (c) 2016 The Gitea Authors +Copyright (c) 2015 The Gogs Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +` + + r, err := testBlob.Data() + assert.NoError(t, err) + require.NotNil(t, r) + + data, err := ioutil.ReadAll(r) + assert.NoError(t, err) + assert.Equal(t, output, string(data)) +} + +func Benchmark_Blob_Data(b *testing.B) { + for i := 0; i < b.N; i++ { + r, err := testBlob.Data() + if err != nil { + b.Fatal(err) + } + ioutil.ReadAll(r) + } +} + +func Benchmark_Blob_DataPipeline(b *testing.B) { + stdout := new(bytes.Buffer) + for i := 0; i < b.N; i++ { + stdout.Reset() + if err := testBlob.DataPipeline(stdout, nil); err != nil { + b.Fatal(err) + } + } +} diff --git a/modules/git/cache.go b/modules/git/cache.go new file mode 100644 index 0000000000..dbbbafae4c --- /dev/null +++ b/modules/git/cache.go @@ -0,0 +1,11 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +// LastCommitCache cache +type LastCommitCache interface { + Get(repoPath, ref, entryPath string) (*Commit, error) + Put(repoPath, ref, entryPath string, commit *Commit) error +} diff --git a/modules/git/command.go b/modules/git/command.go new file mode 100644 index 0000000000..d354635119 --- /dev/null +++ b/modules/git/command.go @@ -0,0 +1,137 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "bytes" + "context" + "fmt" + "io" + "os/exec" + "strings" + "time" +) + +var ( + // GlobalCommandArgs global command args for external package setting + GlobalCommandArgs []string + + // DefaultCommandExecutionTimeout default command execution timeout duration + DefaultCommandExecutionTimeout = 60 * time.Second +) + +// Command represents a command with its subcommands or arguments. +type Command struct { + name string + args []string +} + +func (c *Command) String() string { + if len(c.args) == 0 { + return c.name + } + return fmt.Sprintf("%s %s", c.name, strings.Join(c.args, " ")) +} + +// NewCommand creates and returns a new Git Command based on given command and arguments. +func NewCommand(args ...string) *Command { + // Make an explicit copy of GlobalCommandArgs, otherwise append might overwrite it + cargs := make([]string, len(GlobalCommandArgs)) + copy(cargs, GlobalCommandArgs) + return &Command{ + name: "git", + args: append(cargs, args...), + } +} + +// AddArguments adds new argument(s) to the command. +func (c *Command) AddArguments(args ...string) *Command { + c.args = append(c.args, args...) + return c +} + +// RunInDirTimeoutPipeline executes the command in given directory with given timeout, +// it pipes stdout and stderr to given io.Writer. +func (c *Command) RunInDirTimeoutPipeline(timeout time.Duration, dir string, stdout, stderr io.Writer) error { + if timeout == -1 { + timeout = DefaultCommandExecutionTimeout + } + + if len(dir) == 0 { + log(c.String()) + } else { + log("%s: %v", dir, c) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, c.name, c.args...) + cmd.Dir = dir + cmd.Stdout = stdout + cmd.Stderr = stderr + if err := cmd.Start(); err != nil { + return err + } + + if err := cmd.Wait(); err != nil { + return err + } + + return ctx.Err() +} + +// RunInDirTimeout executes the command in given directory with given timeout, +// and returns stdout in []byte and error (combined with stderr). +func (c *Command) RunInDirTimeout(timeout time.Duration, dir string) ([]byte, error) { + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + if err := c.RunInDirTimeoutPipeline(timeout, dir, stdout, stderr); err != nil { + return nil, concatenateError(err, stderr.String()) + } + + if stdout.Len() > 0 { + log("stdout:\n%s", stdout.Bytes()[:1024]) + } + return stdout.Bytes(), nil +} + +// RunInDirPipeline executes the command in given directory, +// it pipes stdout and stderr to given io.Writer. +func (c *Command) RunInDirPipeline(dir string, stdout, stderr io.Writer) error { + return c.RunInDirTimeoutPipeline(-1, dir, stdout, stderr) +} + +// RunInDirBytes executes the command in given directory +// and returns stdout in []byte and error (combined with stderr). +func (c *Command) RunInDirBytes(dir string) ([]byte, error) { + return c.RunInDirTimeout(-1, dir) +} + +// RunInDir executes the command in given directory +// and returns stdout in string and error (combined with stderr). +func (c *Command) RunInDir(dir string) (string, error) { + stdout, err := c.RunInDirTimeout(-1, dir) + if err != nil { + return "", err + } + return string(stdout), nil +} + +// RunTimeout executes the command in default working directory with given timeout, +// and returns stdout in string and error (combined with stderr). +func (c *Command) RunTimeout(timeout time.Duration) (string, error) { + stdout, err := c.RunInDirTimeout(timeout, "") + if err != nil { + return "", err + } + return string(stdout), nil +} + +// Run executes the command in default working directory +// and returns stdout in string and error (combined with stderr). +func (c *Command) Run() (string, error) { + return c.RunTimeout(-1) +} diff --git a/modules/git/command_test.go b/modules/git/command_test.go new file mode 100644 index 0000000000..2f35b3c329 --- /dev/null +++ b/modules/git/command_test.go @@ -0,0 +1,41 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// +build race + +package git + +import ( + "context" + "testing" + "time" +) + +func TestRunInDirTimeoutPipelineNoTimeout(t *testing.T) { + + maxLoops := 1000 + + // 'git --version' does not block so it must be finished before the timeout triggered. + cmd := NewCommand("--version") + for i := 0; i < maxLoops; i++ { + if err := cmd.RunInDirTimeoutPipeline(-1, "", nil, nil); err != nil { + t.Fatal(err) + } + } +} + +func TestRunInDirTimeoutPipelineAlwaysTimeout(t *testing.T) { + + maxLoops := 1000 + + // 'git hash-object --stdin' blocks on stdin so we can have the timeout triggered. + cmd := NewCommand("hash-object --stdin") + for i := 0; i < maxLoops; i++ { + if err := cmd.RunInDirTimeoutPipeline(1*time.Microsecond, "", nil, nil); err != nil { + if err != context.DeadlineExceeded { + t.Fatalf("Testing %d/%d: %v", i, maxLoops, err) + } + } + } +} diff --git a/modules/git/commit.go b/modules/git/commit.go new file mode 100644 index 0000000000..36b8d54565 --- /dev/null +++ b/modules/git/commit.go @@ -0,0 +1,349 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "bufio" + "bytes" + "container/list" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +// Commit represents a git commit. +type Commit struct { + Branch string // Branch this commit belongs to + Tree + ID SHA1 // The ID of this commit object + Author *Signature + Committer *Signature + CommitMessage string + Signature *CommitGPGSignature + + parents []SHA1 // SHA1 strings + submoduleCache *ObjectCache +} + +// CommitGPGSignature represents a git commit signature part. +type CommitGPGSignature struct { + Signature string + Payload string //TODO check if can be reconstruct from the rest of commit information to not have duplicate data +} + +// similar to https://github.com/git/git/blob/3bc53220cb2dcf709f7a027a3f526befd021d858/commit.c#L1128 +func newGPGSignatureFromCommitline(data []byte, signatureStart int, tag bool) (*CommitGPGSignature, error) { + sig := new(CommitGPGSignature) + signatureEnd := bytes.LastIndex(data, []byte("-----END PGP SIGNATURE-----")) + if signatureEnd == -1 { + return nil, fmt.Errorf("end of commit signature not found") + } + sig.Signature = strings.Replace(string(data[signatureStart:signatureEnd+27]), "\n ", "\n", -1) + if tag { + sig.Payload = string(data[:signatureStart-1]) + } else { + sig.Payload = string(data[:signatureStart-8]) + string(data[signatureEnd+27:]) + } + return sig, nil +} + +// Message returns the commit message. Same as retrieving CommitMessage directly. +func (c *Commit) Message() string { + return c.CommitMessage +} + +// Summary returns first line of commit message. +func (c *Commit) Summary() string { + return strings.Split(strings.TrimSpace(c.CommitMessage), "\n")[0] +} + +// ParentID returns oid of n-th parent (0-based index). +// It returns nil if no such parent exists. +func (c *Commit) ParentID(n int) (SHA1, error) { + if n >= len(c.parents) { + return SHA1{}, ErrNotExist{"", ""} + } + return c.parents[n], nil +} + +// Parent returns n-th parent (0-based index) of the commit. +func (c *Commit) Parent(n int) (*Commit, error) { + id, err := c.ParentID(n) + if err != nil { + return nil, err + } + parent, err := c.repo.getCommit(id) + if err != nil { + return nil, err + } + return parent, nil +} + +// ParentCount returns number of parents of the commit. +// 0 if this is the root commit, otherwise 1,2, etc. +func (c *Commit) ParentCount() int { + return len(c.parents) +} + +func isImageFile(data []byte) (string, bool) { + contentType := http.DetectContentType(data) + if strings.Index(contentType, "image/") != -1 { + return contentType, true + } + return contentType, false +} + +// IsImageFile is a file image type +func (c *Commit) IsImageFile(name string) bool { + blob, err := c.GetBlobByPath(name) + if err != nil { + return false + } + + dataRc, err := blob.DataAsync() + if err != nil { + return false + } + defer dataRc.Close() + buf := make([]byte, 1024) + n, _ := dataRc.Read(buf) + buf = buf[:n] + _, isImage := isImageFile(buf) + return isImage +} + +// GetCommitByPath return the commit of relative path object. +func (c *Commit) GetCommitByPath(relpath string) (*Commit, error) { + return c.repo.getCommitByPathWithID(c.ID, relpath) +} + +// AddChanges marks local changes to be ready for commit. +func AddChanges(repoPath string, all bool, files ...string) error { + cmd := NewCommand("add") + if all { + cmd.AddArguments("--all") + } + _, err := cmd.AddArguments(files...).RunInDir(repoPath) + return err +} + +// CommitChangesOptions the options when a commit created +type CommitChangesOptions struct { + Committer *Signature + Author *Signature + Message string +} + +// CommitChanges commits local changes with given committer, author and message. +// If author is nil, it will be the same as committer. +func CommitChanges(repoPath string, opts CommitChangesOptions) error { + cmd := NewCommand() + if opts.Committer != nil { + cmd.AddArguments("-c", "user.name="+opts.Committer.Name, "-c", "user.email="+opts.Committer.Email) + } + cmd.AddArguments("commit") + + if opts.Author == nil { + opts.Author = opts.Committer + } + if opts.Author != nil { + cmd.AddArguments(fmt.Sprintf("--author='%s <%s>'", opts.Author.Name, opts.Author.Email)) + } + cmd.AddArguments("-m", opts.Message) + + _, err := cmd.RunInDir(repoPath) + // No stderr but exit status 1 means nothing to commit. + if err != nil && err.Error() == "exit status 1" { + return nil + } + return err +} + +func commitsCount(repoPath, revision, relpath string) (int64, error) { + var cmd *Command + cmd = NewCommand("rev-list", "--count") + cmd.AddArguments(revision) + if len(relpath) > 0 { + cmd.AddArguments("--", relpath) + } + + stdout, err := cmd.RunInDir(repoPath) + if err != nil { + return 0, err + } + + return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64) +} + +// CommitsCount returns number of total commits of until given revision. +func CommitsCount(repoPath, revision string) (int64, error) { + return commitsCount(repoPath, revision, "") +} + +// CommitsCount returns number of total commits of until current revision. +func (c *Commit) CommitsCount() (int64, error) { + return CommitsCount(c.repo.Path, c.ID.String()) +} + +// CommitsByRange returns the specific page commits before current revision, every page's number default by CommitsRangeSize +func (c *Commit) CommitsByRange(page int) (*list.List, error) { + return c.repo.commitsByRange(c.ID, page) +} + +// CommitsBefore returns all the commits before current revision +func (c *Commit) CommitsBefore() (*list.List, error) { + return c.repo.getCommitsBefore(c.ID) +} + +// CommitsBeforeLimit returns num commits before current revision +func (c *Commit) CommitsBeforeLimit(num int) (*list.List, error) { + return c.repo.getCommitsBeforeLimit(c.ID, num) +} + +// CommitsBeforeUntil returns the commits between commitID to current revision +func (c *Commit) CommitsBeforeUntil(commitID string) (*list.List, error) { + endCommit, err := c.repo.GetCommit(commitID) + if err != nil { + return nil, err + } + return c.repo.CommitsBetween(c, endCommit) +} + +// SearchCommits returns the commits match the keyword before current revision +func (c *Commit) SearchCommits(keyword string, all bool) (*list.List, error) { + return c.repo.searchCommits(c.ID, keyword, all) +} + +// GetFilesChangedSinceCommit get all changed file names between pastCommit to current revision +func (c *Commit) GetFilesChangedSinceCommit(pastCommit string) ([]string, error) { + return c.repo.getFilesChanged(pastCommit, c.ID.String()) +} + +// GetSubModules get all the sub modules of current revision git tree +func (c *Commit) GetSubModules() (*ObjectCache, error) { + if c.submoduleCache != nil { + return c.submoduleCache, nil + } + + entry, err := c.GetTreeEntryByPath(".gitmodules") + if err != nil { + if _, ok := err.(ErrNotExist); ok { + return nil, nil + } + return nil, err + } + rd, err := entry.Blob().Data() + if err != nil { + return nil, err + } + + scanner := bufio.NewScanner(rd) + c.submoduleCache = newObjectCache() + var ismodule bool + var path string + for scanner.Scan() { + if strings.HasPrefix(scanner.Text(), "[submodule") { + ismodule = true + continue + } + if ismodule { + fields := strings.Split(scanner.Text(), "=") + k := strings.TrimSpace(fields[0]) + if k == "path" { + path = strings.TrimSpace(fields[1]) + } else if k == "url" { + c.submoduleCache.Set(path, &SubModule{path, strings.TrimSpace(fields[1])}) + ismodule = false + } + } + } + + return c.submoduleCache, nil +} + +// GetSubModule get the sub module according entryname +func (c *Commit) GetSubModule(entryname string) (*SubModule, error) { + modules, err := c.GetSubModules() + if err != nil { + return nil, err + } + + if modules != nil { + module, has := modules.Get(entryname) + if has { + return module.(*SubModule), nil + } + } + return nil, nil +} + +// CommitFileStatus represents status of files in a commit. +type CommitFileStatus struct { + Added []string + Removed []string + Modified []string +} + +// NewCommitFileStatus creates a CommitFileStatus +func NewCommitFileStatus() *CommitFileStatus { + return &CommitFileStatus{ + []string{}, []string{}, []string{}, + } +} + +// GetCommitFileStatus returns file status of commit in given repository. +func GetCommitFileStatus(repoPath, commitID string) (*CommitFileStatus, error) { + stdout, w := io.Pipe() + done := make(chan struct{}) + fileStatus := NewCommitFileStatus() + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 2 { + continue + } + + switch fields[0][0] { + case 'A': + fileStatus.Added = append(fileStatus.Added, fields[1]) + case 'D': + fileStatus.Removed = append(fileStatus.Removed, fields[1]) + case 'M': + fileStatus.Modified = append(fileStatus.Modified, fields[1]) + } + } + done <- struct{}{} + }() + + stderr := new(bytes.Buffer) + err := NewCommand("show", "--name-status", "--pretty=format:''", commitID).RunInDirPipeline(repoPath, w, stderr) + w.Close() // Close writer to exit parsing goroutine + if err != nil { + return nil, concatenateError(err, stderr.String()) + } + + <-done + return fileStatus, nil +} + +// GetFullCommitID returns full length (40) of commit ID by given short SHA in a repository. +func GetFullCommitID(repoPath, shortID string) (string, error) { + if len(shortID) >= 40 { + return shortID, nil + } + + commitID, err := NewCommand("rev-parse", shortID).RunInDir(repoPath) + if err != nil { + if strings.Contains(err.Error(), "exit status 128") { + return "", ErrNotExist{shortID, ""} + } + return "", err + } + return strings.TrimSpace(commitID), nil +} diff --git a/modules/git/commit_archive.go b/modules/git/commit_archive.go new file mode 100644 index 0000000000..e13825a962 --- /dev/null +++ b/modules/git/commit_archive.go @@ -0,0 +1,37 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "fmt" + "path/filepath" + "strings" +) + +// ArchiveType archive types +type ArchiveType int + +const ( + // ZIP zip archive type + ZIP ArchiveType = iota + 1 + // TARGZ tar gz archive type + TARGZ +) + +// CreateArchive create archive content to the target path +func (c *Commit) CreateArchive(target string, archiveType ArchiveType) error { + var format string + switch archiveType { + case ZIP: + format = "zip" + case TARGZ: + format = "tar.gz" + default: + return fmt.Errorf("unknown format: %v", archiveType) + } + + _, err := NewCommand("archive", "--prefix="+filepath.Base(strings.TrimSuffix(c.repo.Path, ".git"))+"/", "--format="+format, "-o", target, c.ID.String()).RunInDir(c.repo.Path) + return err +} diff --git a/modules/git/commit_info.go b/modules/git/commit_info.go new file mode 100644 index 0000000000..971082be1f --- /dev/null +++ b/modules/git/commit_info.go @@ -0,0 +1,329 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "bufio" + "context" + "fmt" + "os/exec" + "path" + "runtime" + "strconv" + "strings" + "sync" + "time" +) + +const ( + // parameters for searching for commit infos. If the untargeted search has + // not found any entries in the past 5 commits, and 12 or fewer entries + // remain, then we'll just let the targeted-searching threads finish off, + // and stop the untargeted search to not interfere. + deferToTargetedSearchColdStreak = 5 + deferToTargetedSearchNumRemainingEntries = 12 +) + +// getCommitsInfoState shared state while getting commit info for entries +type getCommitsInfoState struct { + lock sync.Mutex + /* read-only fields, can be read without the mutex */ + // entries and entryPaths are read-only after initialization, so they can + // safely be read without the mutex + entries []*TreeEntry + // set of filepaths to get info for + entryPaths map[string]struct{} + treePath string + headCommit *Commit + + /* mutable fields, must hold mutex to read or write */ + // map from filepath to commit + commits map[string]*Commit + // set of filepaths that have been or are being searched for in a target search + targetedPaths map[string]struct{} +} + +func (state *getCommitsInfoState) numRemainingEntries() int { + state.lock.Lock() + defer state.lock.Unlock() + return len(state.entries) - len(state.commits) +} + +// getTargetEntryPath Returns the next path for a targeted-searching thread to +// search for, or returns the empty string if nothing left to search for +func (state *getCommitsInfoState) getTargetedEntryPath() string { + var targetedEntryPath string + state.lock.Lock() + defer state.lock.Unlock() + for _, entry := range state.entries { + entryPath := path.Join(state.treePath, entry.Name()) + if _, ok := state.commits[entryPath]; ok { + continue + } else if _, ok = state.targetedPaths[entryPath]; ok { + continue + } + targetedEntryPath = entryPath + state.targetedPaths[entryPath] = struct{}{} + break + } + return targetedEntryPath +} + +// repeatedly perform targeted searches for unpopulated entries +func targetedSearch(state *getCommitsInfoState, done chan error, cache LastCommitCache) { + for { + entryPath := state.getTargetedEntryPath() + if len(entryPath) == 0 { + done <- nil + return + } + if cache != nil { + commit, err := cache.Get(state.headCommit.repo.Path, state.headCommit.ID.String(), entryPath) + if err == nil && commit != nil { + state.update(entryPath, commit) + continue + } + } + command := NewCommand("rev-list", "-1", state.headCommit.ID.String(), "--", entryPath) + output, err := command.RunInDir(state.headCommit.repo.Path) + if err != nil { + done <- err + return + } + id, err := NewIDFromString(strings.TrimSpace(output)) + if err != nil { + done <- err + return + } + commit, err := state.headCommit.repo.getCommit(id) + if err != nil { + done <- err + return + } + state.update(entryPath, commit) + if cache != nil { + cache.Put(state.headCommit.repo.Path, state.headCommit.ID.String(), entryPath, commit) + } + } +} + +func initGetCommitInfoState(entries Entries, headCommit *Commit, treePath string) *getCommitsInfoState { + entryPaths := make(map[string]struct{}, len(entries)) + for _, entry := range entries { + entryPaths[path.Join(treePath, entry.Name())] = struct{}{} + } + if treePath = path.Clean(treePath); treePath == "." { + treePath = "" + } + return &getCommitsInfoState{ + entries: entries, + entryPaths: entryPaths, + commits: make(map[string]*Commit, len(entries)), + targetedPaths: make(map[string]struct{}, len(entries)), + treePath: treePath, + headCommit: headCommit, + } +} + +// GetCommitsInfo gets information of all commits that are corresponding to these entries +func (tes Entries) GetCommitsInfo(commit *Commit, treePath string, cache LastCommitCache) ([][]interface{}, error) { + state := initGetCommitInfoState(tes, commit, treePath) + if err := getCommitsInfo(state, cache); err != nil { + return nil, err + } + if len(state.commits) < len(state.entryPaths) { + return nil, fmt.Errorf("could not find commits for all entries") + } + + commitsInfo := make([][]interface{}, len(tes)) + for i, entry := range tes { + commit, ok := state.commits[path.Join(treePath, entry.Name())] + if !ok { + return nil, fmt.Errorf("could not find commit for %s", entry.Name()) + } + switch entry.Type { + case ObjectCommit: + subModuleURL := "" + if subModule, err := state.headCommit.GetSubModule(entry.Name()); err != nil { + return nil, err + } else if subModule != nil { + subModuleURL = subModule.URL + } + subModuleFile := NewSubModuleFile(commit, subModuleURL, entry.ID.String()) + commitsInfo[i] = []interface{}{entry, subModuleFile} + default: + commitsInfo[i] = []interface{}{entry, commit} + } + } + return commitsInfo, nil +} + +func (state *getCommitsInfoState) cleanEntryPath(rawEntryPath string) (string, error) { + if rawEntryPath[0] == '"' { + var err error + rawEntryPath, err = strconv.Unquote(rawEntryPath) + if err != nil { + return rawEntryPath, err + } + } + var entryNameStartIndex int + if len(state.treePath) > 0 { + entryNameStartIndex = len(state.treePath) + 1 + } + + if index := strings.IndexByte(rawEntryPath[entryNameStartIndex:], '/'); index >= 0 { + return rawEntryPath[:entryNameStartIndex+index], nil + } + return rawEntryPath, nil +} + +// update report that the given path was last modified by the given commit. +// Returns whether state.commits was updated +func (state *getCommitsInfoState) update(entryPath string, commit *Commit) bool { + if _, ok := state.entryPaths[entryPath]; !ok { + return false + } + + var updated bool + state.lock.Lock() + defer state.lock.Unlock() + if _, ok := state.commits[entryPath]; !ok { + state.commits[entryPath] = commit + updated = true + } + return updated +} + +const getCommitsInfoPretty = "--pretty=format:%H %ct %s" + +func getCommitsInfo(state *getCommitsInfoState, cache LastCommitCache) error { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + + args := []string{"log", state.headCommit.ID.String(), getCommitsInfoPretty, "--name-status", "-c"} + if len(state.treePath) > 0 { + args = append(args, "--", state.treePath) + } + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = state.headCommit.repo.Path + + readCloser, err := cmd.StdoutPipe() + if err != nil { + return err + } + + if err := cmd.Start(); err != nil { + return err + } + // it's okay to ignore the error returned by cmd.Wait(); we expect the + // subprocess to sometimes have a non-zero exit status, since we may + // prematurely close stdout, resulting in a broken pipe. + defer cmd.Wait() + + numThreads := runtime.NumCPU() + done := make(chan error, numThreads) + for i := 0; i < numThreads; i++ { + go targetedSearch(state, done, cache) + } + + scanner := bufio.NewScanner(readCloser) + err = state.processGitLogOutput(scanner) + + // it is important that we close stdout here; if we do not close + // stdout, the subprocess will keep running, and the deffered call + // cmd.Wait() may block for a long time. + if closeErr := readCloser.Close(); closeErr != nil && err == nil { + err = closeErr + } + + for i := 0; i < numThreads; i++ { + doneErr := <-done + if doneErr != nil && err == nil { + err = doneErr + } + } + return err +} + +func (state *getCommitsInfoState) processGitLogOutput(scanner *bufio.Scanner) error { + // keep a local cache of seen paths to avoid acquiring a lock for paths + // we've already seen + seenPaths := make(map[string]struct{}, len(state.entryPaths)) + // number of consecutive commits without any finds + coldStreak := 0 + var commit *Commit + var err error + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 { // in-between commits + numRemainingEntries := state.numRemainingEntries() + if numRemainingEntries == 0 { + break + } + if coldStreak >= deferToTargetedSearchColdStreak && + numRemainingEntries <= deferToTargetedSearchNumRemainingEntries { + // stop this untargeted search, and let the targeted-search threads + // finish the work + break + } + continue + } + if line[0] >= 'A' && line[0] <= 'X' { // a file was changed by the current commit + // look for the last tab, since for copies (C) and renames (R) two + // filenames are printed: src, then dest + tabIndex := strings.LastIndexByte(line, '\t') + if tabIndex < 1 { + return fmt.Errorf("misformatted line: %s", line) + } + entryPath, err := state.cleanEntryPath(line[tabIndex+1:]) + if err != nil { + return err + } + if _, ok := seenPaths[entryPath]; !ok { + if state.update(entryPath, commit) { + coldStreak = 0 + } + seenPaths[entryPath] = struct{}{} + } + continue + } + + // a new commit + commit, err = parseCommitInfo(line) + if err != nil { + return err + } + coldStreak++ + } + return scanner.Err() +} + +// parseCommitInfo parse a commit from a line of `git log` output. Expects the +// line to be formatted according to getCommitsInfoPretty. +func parseCommitInfo(line string) (*Commit, error) { + if len(line) < 43 { + return nil, fmt.Errorf("invalid git output: %s", line) + } + ref, err := NewIDFromString(line[:40]) + if err != nil { + return nil, err + } + spaceIndex := strings.IndexByte(line[41:], ' ') + if spaceIndex < 0 { + return nil, fmt.Errorf("invalid git output: %s", line) + } + unixSeconds, err := strconv.Atoi(line[41 : 41+spaceIndex]) + if err != nil { + return nil, err + } + message := line[spaceIndex+42:] + return &Commit{ + ID: ref, + CommitMessage: message, + Committer: &Signature{ + When: time.Unix(int64(unixSeconds), 0), + }, + }, nil +} diff --git a/modules/git/commit_info_test.go b/modules/git/commit_info_test.go new file mode 100644 index 0000000000..120a9a737c --- /dev/null +++ b/modules/git/commit_info_test.go @@ -0,0 +1,117 @@ +package git + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const testReposDir = "tests/repos/" +const benchmarkReposDir = "benchmark/repos/" + +func cloneRepo(url, dir, name string) (string, error) { + repoDir := filepath.Join(dir, name) + if _, err := os.Stat(repoDir); err == nil { + return repoDir, nil + } + return repoDir, Clone(url, repoDir, CloneRepoOptions{ + Mirror: false, + Bare: false, + Quiet: true, + Timeout: 5 * time.Minute, + }) +} + +func testGetCommitsInfo(t *testing.T, repo1 *Repository) { + // these test case are specific to the repo1 test repo + testCases := []struct { + CommitID string + Path string + ExpectedIDs map[string]string + }{ + {"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", "", map[string]string{ + "file1.txt": "95bb4d39648ee7e325106df01a621c530863a653", + "file2.txt": "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", + }}, + {"2839944139e0de9737a044f78b0e4b40d989a9e3", "", map[string]string{ + "file1.txt": "2839944139e0de9737a044f78b0e4b40d989a9e3", + "branch1.txt": "9c9aef8dd84e02bc7ec12641deb4c930a7c30185", + }}, + {"5c80b0245c1c6f8343fa418ec374b13b5d4ee658", "branch2", map[string]string{ + "branch2.txt": "5c80b0245c1c6f8343fa418ec374b13b5d4ee658", + }}, + } + for _, testCase := range testCases { + commit, err := repo1.GetCommit(testCase.CommitID) + assert.NoError(t, err) + tree, err := commit.Tree.SubTree(testCase.Path) + assert.NoError(t, err) + entries, err := tree.ListEntries() + assert.NoError(t, err) + commitsInfo, err := entries.GetCommitsInfo(commit, testCase.Path, nil) + assert.NoError(t, err) + assert.Len(t, commitsInfo, len(testCase.ExpectedIDs)) + for _, commitInfo := range commitsInfo { + entry := commitInfo[0].(*TreeEntry) + commit := commitInfo[1].(*Commit) + expectedID, ok := testCase.ExpectedIDs[entry.Name()] + if !assert.True(t, ok) { + continue + } + assert.Equal(t, expectedID, commit.ID.String()) + } + } +} + +func TestEntries_GetCommitsInfo(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := OpenRepository(bareRepo1Path) + assert.NoError(t, err) + testGetCommitsInfo(t, bareRepo1) + + clonedPath, err := cloneRepo(bareRepo1Path, testReposDir, "repo1_TestEntries_GetCommitsInfo") + assert.NoError(t, err) + defer os.RemoveAll(clonedPath) + clonedRepo1, err := OpenRepository(clonedPath) + assert.NoError(t, err) + testGetCommitsInfo(t, clonedRepo1) +} + +func BenchmarkEntries_GetCommitsInfo(b *testing.B) { + benchmarks := []struct { + url string + name string + }{ + {url: "https://github.com/go-gitea/gitea.git", name: "gitea"}, + {url: "https://github.com/ethantkoenig/manyfiles.git", name: "manyfiles"}, + {url: "https://github.com/moby/moby.git", name: "moby"}, + {url: "https://github.com/golang/go.git", name: "go"}, + {url: "https://github.com/torvalds/linux.git", name: "linux"}, + } + for _, benchmark := range benchmarks { + var commit *Commit + var entries Entries + if repoPath, err := cloneRepo(benchmark.url, benchmarkReposDir, benchmark.name); err != nil { + b.Fatal(err) + } else if repo, err := OpenRepository(repoPath); err != nil { + b.Fatal(err) + } else if commit, err = repo.GetBranchCommit("master"); err != nil { + b.Fatal(err) + } else if entries, err = commit.Tree.ListEntries(); err != nil { + b.Fatal(err) + } + entries.Sort() + b.ResetTimer() + b.Run(benchmark.name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + _, err := entries.GetCommitsInfo(commit, "", nil) + if err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/modules/git/commit_test.go b/modules/git/commit_test.go new file mode 100644 index 0000000000..bbe12b5aec --- /dev/null +++ b/modules/git/commit_test.go @@ -0,0 +1,38 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCommitsCount(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + + commitsCount, err := CommitsCount(bareRepo1Path, "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0") + assert.NoError(t, err) + assert.Equal(t, int64(3), commitsCount) +} + +func TestGetFullCommitID(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + + id, err := GetFullCommitID(bareRepo1Path, "8006ff9a") + assert.NoError(t, err) + assert.Equal(t, "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0", id) +} + +func TestGetFullCommitIDError(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + + id, err := GetFullCommitID(bareRepo1Path, "unknown") + assert.Empty(t, id) + if assert.Error(t, err) { + assert.EqualError(t, err, "object does not exist [id: unknown, rel_path: ]") + } +} diff --git a/modules/git/error.go b/modules/git/error.go new file mode 100644 index 0000000000..1aae5a37a2 --- /dev/null +++ b/modules/git/error.go @@ -0,0 +1,66 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "fmt" + "time" +) + +// ErrExecTimeout error when exec timed out +type ErrExecTimeout struct { + Duration time.Duration +} + +// IsErrExecTimeout if some error is ErrExecTimeout +func IsErrExecTimeout(err error) bool { + _, ok := err.(ErrExecTimeout) + return ok +} + +func (err ErrExecTimeout) Error() string { + return fmt.Sprintf("execution is timeout [duration: %v]", err.Duration) +} + +// ErrNotExist commit not exist error +type ErrNotExist struct { + ID string + RelPath string +} + +// IsErrNotExist if some error is ErrNotExist +func IsErrNotExist(err error) bool { + _, ok := err.(ErrNotExist) + return ok +} + +func (err ErrNotExist) Error() string { + return fmt.Sprintf("object does not exist [id: %s, rel_path: %s]", err.ID, err.RelPath) +} + +// ErrBadLink entry.FollowLink error +type ErrBadLink struct { + Name string + Message string +} + +func (err ErrBadLink) Error() string { + return fmt.Sprintf("%s: %s", err.Name, err.Message) +} + +// ErrUnsupportedVersion error when required git version not matched +type ErrUnsupportedVersion struct { + Required string +} + +// IsErrUnsupportedVersion if some error is ErrUnsupportedVersion +func IsErrUnsupportedVersion(err error) bool { + _, ok := err.(ErrUnsupportedVersion) + return ok +} + +func (err ErrUnsupportedVersion) Error() string { + return fmt.Sprintf("Operation requires higher version [required: %s]", err.Required) +} diff --git a/modules/git/git.go b/modules/git/git.go new file mode 100644 index 0000000000..150b80fb07 --- /dev/null +++ b/modules/git/git.go @@ -0,0 +1,91 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "fmt" + "strings" + "time" + + "github.com/mcuadros/go-version" +) + +// Version return this package's current version +func Version() string { + return "0.4.2" +} + +var ( + // Debug enables verbose logging on everything. + // This should be false in case Gogs starts in SSH mode. + Debug = false + // Prefix the log prefix + Prefix = "[git-module] " + // GitVersionRequired is the minimum Git version required + GitVersionRequired = "1.7.2" +) + +func log(format string, args ...interface{}) { + if !Debug { + return + } + + fmt.Print(Prefix) + if len(args) == 0 { + fmt.Println(format) + } else { + fmt.Printf(format+"\n", args...) + } +} + +var gitVersion string + +// BinVersion returns current Git version from shell. +func BinVersion() (string, error) { + if len(gitVersion) > 0 { + return gitVersion, nil + } + + stdout, err := NewCommand("version").Run() + if err != nil { + return "", err + } + + fields := strings.Fields(stdout) + if len(fields) < 3 { + return "", fmt.Errorf("not enough output: %s", stdout) + } + + // Handle special case on Windows. + i := strings.Index(fields[2], "windows") + if i >= 1 { + gitVersion = fields[2][:i-1] + return gitVersion, nil + } + + gitVersion = fields[2] + return gitVersion, nil +} + +func init() { + gitVersion, err := BinVersion() + if err != nil { + panic(fmt.Sprintf("Git version missing: %v", err)) + } + if version.Compare(gitVersion, GitVersionRequired, "<") { + panic(fmt.Sprintf("Git version not supported. Requires version > %v", GitVersionRequired)) + } +} + +// Fsck verifies the connectivity and validity of the objects in the database +func Fsck(repoPath string, timeout time.Duration, args ...string) error { + // Make sure timeout makes sense. + if timeout <= 0 { + timeout = -1 + } + _, err := NewCommand("fsck").AddArguments(args...).RunInDirTimeout(timeout, repoPath) + return err +} diff --git a/modules/git/hook.go b/modules/git/hook.go new file mode 100644 index 0000000000..18c00b5838 --- /dev/null +++ b/modules/git/hook.go @@ -0,0 +1,135 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "errors" + "io/ioutil" + "os" + "path" + "path/filepath" + "strings" + + "github.com/Unknwon/com" +) + +// hookNames is a list of Git server hooks' name that are supported. +var hookNames = []string{ + "pre-receive", + "update", + "post-receive", +} + +var ( + // ErrNotValidHook error when a git hook is not valid + ErrNotValidHook = errors.New("not a valid Git hook") +) + +// IsValidHookName returns true if given name is a valid Git hook. +func IsValidHookName(name string) bool { + for _, hn := range hookNames { + if hn == name { + return true + } + } + return false +} + +// Hook represents a Git hook. +type Hook struct { + name string + IsActive bool // Indicates whether repository has this hook. + Content string // Content of hook if it's active. + Sample string // Sample content from Git. + path string // Hook file path. +} + +// GetHook returns a Git hook by given name and repository. +func GetHook(repoPath, name string) (*Hook, error) { + if !IsValidHookName(name) { + return nil, ErrNotValidHook + } + h := &Hook{ + name: name, + path: path.Join(repoPath, "hooks", name+".d", name), + } + samplePath := filepath.Join(repoPath, "hooks", name+".sample") + if isFile(h.path) { + data, err := ioutil.ReadFile(h.path) + if err != nil { + return nil, err + } + h.IsActive = true + h.Content = string(data) + } else if isFile(samplePath) { + data, err := ioutil.ReadFile(samplePath) + if err != nil { + return nil, err + } + h.Sample = string(data) + } + return h, nil +} + +// Name return the name of the hook +func (h *Hook) Name() string { + return h.name +} + +// Update updates hook settings. +func (h *Hook) Update() error { + if len(strings.TrimSpace(h.Content)) == 0 { + if isExist(h.path) { + err := os.Remove(h.path) + if err != nil { + return err + } + } + h.IsActive = false + return nil + } + err := ioutil.WriteFile(h.path, []byte(strings.Replace(h.Content, "\r", "", -1)), os.ModePerm) + if err != nil { + return err + } + h.IsActive = true + return nil +} + +// ListHooks returns a list of Git hooks of given repository. +func ListHooks(repoPath string) (_ []*Hook, err error) { + if !isDir(path.Join(repoPath, "hooks")) { + return nil, errors.New("hooks path does not exist") + } + + hooks := make([]*Hook, len(hookNames)) + for i, name := range hookNames { + hooks[i], err = GetHook(repoPath, name) + if err != nil { + return nil, err + } + } + return hooks, nil +} + +const ( + // HookPathUpdate hook update path + HookPathUpdate = "hooks/update" +) + +// SetUpdateHook writes given content to update hook of the reposiotry. +func SetUpdateHook(repoPath, content string) (err error) { + log("Setting update hook: %s", repoPath) + hookPath := path.Join(repoPath, HookPathUpdate) + if com.IsExist(hookPath) { + err = os.Remove(hookPath) + } else { + err = os.MkdirAll(path.Dir(hookPath), os.ModePerm) + } + if err != nil { + return err + } + return ioutil.WriteFile(hookPath, []byte(content), 0777) +} diff --git a/modules/git/parse.go b/modules/git/parse.go new file mode 100644 index 0000000000..5c964f16ee --- /dev/null +++ b/modules/git/parse.go @@ -0,0 +1,81 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "bytes" + "fmt" + "strconv" +) + +// ParseTreeEntries parses the output of a `git ls-tree` command. +func ParseTreeEntries(data []byte) ([]*TreeEntry, error) { + return parseTreeEntries(data, nil) +} + +func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) { + entries := make([]*TreeEntry, 0, 10) + for pos := 0; pos < len(data); { + // expect line to be of the form "<mode> <type> <sha>\t<filename>" + entry := new(TreeEntry) + entry.ptree = ptree + if pos+6 > len(data) { + return nil, fmt.Errorf("Invalid ls-tree output: %s", string(data)) + } + switch string(data[pos : pos+6]) { + case "100644": + entry.mode = EntryModeBlob + entry.Type = ObjectBlob + pos += 12 // skip over "100644 blob " + case "100755": + entry.mode = EntryModeExec + entry.Type = ObjectBlob + pos += 12 // skip over "100755 blob " + case "120000": + entry.mode = EntryModeSymlink + entry.Type = ObjectBlob + pos += 12 // skip over "120000 blob " + case "160000": + entry.mode = EntryModeCommit + entry.Type = ObjectCommit + pos += 14 // skip over "160000 object " + case "040000": + entry.mode = EntryModeTree + entry.Type = ObjectTree + pos += 12 // skip over "040000 tree " + default: + return nil, fmt.Errorf("unknown type: %v", string(data[pos:pos+6])) + } + + if pos+40 > len(data) { + return nil, fmt.Errorf("Invalid ls-tree output: %s", string(data)) + } + id, err := NewIDFromString(string(data[pos : pos+40])) + if err != nil { + return nil, fmt.Errorf("Invalid ls-tree output: %v", err) + } + entry.ID = id + pos += 41 // skip over sha and trailing space + + end := pos + bytes.IndexByte(data[pos:], '\n') + if end < pos { + return nil, fmt.Errorf("Invalid ls-tree output: %s", string(data)) + } + + // In case entry name is surrounded by double quotes(it happens only in git-shell). + if data[pos] == '"' { + entry.name, err = strconv.Unquote(string(data[pos:end])) + if err != nil { + return nil, fmt.Errorf("Invalid ls-tree output: %v", err) + } + } else { + entry.name = string(data[pos:end]) + } + + pos = end + 1 + entries = append(entries, entry) + } + return entries, nil +} diff --git a/modules/git/parse_test.go b/modules/git/parse_test.go new file mode 100644 index 0000000000..66936cbdf0 --- /dev/null +++ b/modules/git/parse_test.go @@ -0,0 +1,58 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseTreeEntries(t *testing.T) { + testCases := []struct { + Input string + Expected []*TreeEntry + }{ + { + Input: "", + Expected: []*TreeEntry{}, + }, + { + Input: "100644 blob 61ab7345a1a3bbc590068ccae37b8515cfc5843c\texample/file2.txt\n", + Expected: []*TreeEntry{ + { + mode: EntryModeBlob, + Type: ObjectBlob, + ID: MustIDFromString("61ab7345a1a3bbc590068ccae37b8515cfc5843c"), + name: "example/file2.txt", + }, + }, + }, + { + Input: "120000 blob 61ab7345a1a3bbc590068ccae37b8515cfc5843c\t\"example/\\n.txt\"\n" + + "040000 tree 1d01fb729fb0db5881daaa6030f9f2d3cd3d5ae8\texample\n", + Expected: []*TreeEntry{ + { + ID: MustIDFromString("61ab7345a1a3bbc590068ccae37b8515cfc5843c"), + Type: ObjectBlob, + mode: EntryModeSymlink, + name: "example/\n.txt", + }, + { + ID: MustIDFromString("1d01fb729fb0db5881daaa6030f9f2d3cd3d5ae8"), + Type: ObjectTree, + mode: EntryModeTree, + name: "example", + }, + }, + }, + } + + for _, testCase := range testCases { + entries, err := ParseTreeEntries([]byte(testCase.Input)) + assert.NoError(t, err) + assert.EqualValues(t, testCase.Expected, entries) + } +} diff --git a/modules/git/ref.go b/modules/git/ref.go new file mode 100644 index 0000000000..67b56ac999 --- /dev/null +++ b/modules/git/ref.go @@ -0,0 +1,18 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +// Reference represents a Git ref. +type Reference struct { + Name string + repo *Repository + Object SHA1 // The id of this commit object + Type string +} + +// Commit return the commit of the reference +func (ref *Reference) Commit() (*Commit, error) { + return ref.repo.getCommit(ref.Object) +} diff --git a/modules/git/repo.go b/modules/git/repo.go new file mode 100644 index 0000000000..4306730920 --- /dev/null +++ b/modules/git/repo.go @@ -0,0 +1,287 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "bytes" + "container/list" + "errors" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/Unknwon/com" +) + +// Repository represents a Git repository. +type Repository struct { + Path string + + commitCache *ObjectCache + tagCache *ObjectCache +} + +const prettyLogFormat = `--pretty=format:%H` + +func (repo *Repository) parsePrettyFormatLogToList(logs []byte) (*list.List, error) { + l := list.New() + if len(logs) == 0 { + return l, nil + } + + parts := bytes.Split(logs, []byte{'\n'}) + + for _, commitID := range parts { + commit, err := repo.GetCommit(string(commitID)) + if err != nil { + return nil, err + } + l.PushBack(commit) + } + + return l, nil +} + +// IsRepoURLAccessible checks if given repository URL is accessible. +func IsRepoURLAccessible(url string) bool { + _, err := NewCommand("ls-remote", "-q", "-h", url, "HEAD").Run() + if err != nil { + return false + } + return true +} + +// InitRepository initializes a new Git repository. +func InitRepository(repoPath string, bare bool) error { + os.MkdirAll(repoPath, os.ModePerm) + + cmd := NewCommand("init") + if bare { + cmd.AddArguments("--bare") + } + _, err := cmd.RunInDir(repoPath) + return err +} + +// OpenRepository opens the repository at the given path. +func OpenRepository(repoPath string) (*Repository, error) { + repoPath, err := filepath.Abs(repoPath) + if err != nil { + return nil, err + } else if !isDir(repoPath) { + return nil, errors.New("no such file or directory") + } + + return &Repository{ + Path: repoPath, + commitCache: newObjectCache(), + tagCache: newObjectCache(), + }, nil +} + +// CloneRepoOptions options when clone a repository +type CloneRepoOptions struct { + Timeout time.Duration + Mirror bool + Bare bool + Quiet bool + Branch string +} + +// Clone clones original repository to target path. +func Clone(from, to string, opts CloneRepoOptions) (err error) { + toDir := path.Dir(to) + if err = os.MkdirAll(toDir, os.ModePerm); err != nil { + return err + } + + cmd := NewCommand("clone") + if opts.Mirror { + cmd.AddArguments("--mirror") + } + if opts.Bare { + cmd.AddArguments("--bare") + } + if opts.Quiet { + cmd.AddArguments("--quiet") + } + if len(opts.Branch) > 0 { + cmd.AddArguments("-b", opts.Branch) + } + cmd.AddArguments(from, to) + + if opts.Timeout <= 0 { + opts.Timeout = -1 + } + + _, err = cmd.RunTimeout(opts.Timeout) + return err +} + +// PullRemoteOptions options when pull from remote +type PullRemoteOptions struct { + Timeout time.Duration + All bool + Rebase bool + Remote string + Branch string +} + +// Pull pulls changes from remotes. +func Pull(repoPath string, opts PullRemoteOptions) error { + cmd := NewCommand("pull") + if opts.Rebase { + cmd.AddArguments("--rebase") + } + if opts.All { + cmd.AddArguments("--all") + } else { + cmd.AddArguments(opts.Remote) + cmd.AddArguments(opts.Branch) + } + + if opts.Timeout <= 0 { + opts.Timeout = -1 + } + + _, err := cmd.RunInDirTimeout(opts.Timeout, repoPath) + return err +} + +// PushOptions options when push to remote +type PushOptions struct { + Remote string + Branch string + Force bool +} + +// Push pushs local commits to given remote branch. +func Push(repoPath string, opts PushOptions) error { + cmd := NewCommand("push") + if opts.Force { + cmd.AddArguments("-f") + } + cmd.AddArguments(opts.Remote, opts.Branch) + _, err := cmd.RunInDir(repoPath) + return err +} + +// CheckoutOptions options when heck out some branch +type CheckoutOptions struct { + Timeout time.Duration + Branch string + OldBranch string +} + +// Checkout checkouts a branch +func Checkout(repoPath string, opts CheckoutOptions) error { + cmd := NewCommand("checkout") + if len(opts.OldBranch) > 0 { + cmd.AddArguments("-b") + } + + if opts.Timeout <= 0 { + opts.Timeout = -1 + } + + cmd.AddArguments(opts.Branch) + + if len(opts.OldBranch) > 0 { + cmd.AddArguments(opts.OldBranch) + } + + _, err := cmd.RunInDirTimeout(opts.Timeout, repoPath) + return err +} + +// ResetHEAD resets HEAD to given revision or head of branch. +func ResetHEAD(repoPath string, hard bool, revision string) error { + cmd := NewCommand("reset") + if hard { + cmd.AddArguments("--hard") + } + _, err := cmd.AddArguments(revision).RunInDir(repoPath) + return err +} + +// MoveFile moves a file to another file or directory. +func MoveFile(repoPath, oldTreeName, newTreeName string) error { + _, err := NewCommand("mv").AddArguments(oldTreeName, newTreeName).RunInDir(repoPath) + return err +} + +// CountObject represents repository count objects report +type CountObject struct { + Count int64 + Size int64 + InPack int64 + Packs int64 + SizePack int64 + PrunePack int64 + Garbage int64 + SizeGarbage int64 +} + +const ( + statCount = "count: " + statSize = "size: " + statInpack = "in-pack: " + statPacks = "packs: " + statSizePack = "size-pack: " + statPrunePackage = "prune-package: " + statGarbage = "garbage: " + statSizeGarbage = "size-garbage: " +) + +// GetRepoSize returns disk consumption for repo in path +func GetRepoSize(repoPath string) (*CountObject, error) { + cmd := NewCommand("count-objects", "-v") + stdout, err := cmd.RunInDir(repoPath) + if err != nil { + return nil, err + } + + return parseSize(stdout), nil +} + +// parseSize parses the output from count-objects and return a CountObject +func parseSize(objects string) *CountObject { + repoSize := new(CountObject) + for _, line := range strings.Split(objects, "\n") { + switch { + case strings.HasPrefix(line, statCount): + repoSize.Count = com.StrTo(line[7:]).MustInt64() + case strings.HasPrefix(line, statSize): + repoSize.Size = com.StrTo(line[6:]).MustInt64() * 1024 + case strings.HasPrefix(line, statInpack): + repoSize.InPack = com.StrTo(line[9:]).MustInt64() + case strings.HasPrefix(line, statPacks): + repoSize.Packs = com.StrTo(line[7:]).MustInt64() + case strings.HasPrefix(line, statSizePack): + repoSize.SizePack = com.StrTo(line[11:]).MustInt64() * 1024 + case strings.HasPrefix(line, statPrunePackage): + repoSize.PrunePack = com.StrTo(line[16:]).MustInt64() + case strings.HasPrefix(line, statGarbage): + repoSize.Garbage = com.StrTo(line[9:]).MustInt64() + case strings.HasPrefix(line, statSizeGarbage): + repoSize.SizeGarbage = com.StrTo(line[14:]).MustInt64() * 1024 + } + } + return repoSize +} + +// GetLatestCommitTime returns time for latest commit in repository (across all branches) +func GetLatestCommitTime(repoPath string) (time.Time, error) { + cmd := NewCommand("for-each-ref", "--sort=-committerdate", "refs/heads/", "--count", "1", "--format=%(committerdate)") + stdout, err := cmd.RunInDir(repoPath) + if err != nil { + return time.Time{}, err + } + commitTime := strings.TrimSpace(stdout) + return time.Parse(GitTimeLayout, commitTime) +} diff --git a/modules/git/repo_blame.go b/modules/git/repo_blame.go new file mode 100644 index 0000000000..80ec50e472 --- /dev/null +++ b/modules/git/repo_blame.go @@ -0,0 +1,24 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import "fmt" + +// FileBlame return the Blame object of file +func (repo *Repository) FileBlame(revision, path, file string) ([]byte, error) { + return NewCommand("blame", "--root", "--", file).RunInDirBytes(path) +} + +// LineBlame returns the latest commit at the given line +func (repo *Repository) LineBlame(revision, path, file string, line uint) (*Commit, error) { + res, err := NewCommand("blame", fmt.Sprintf("-L %d,%d", line, line), "-p", revision, "--", file).RunInDir(path) + if err != nil { + return nil, err + } + if len(res) < 40 { + return nil, fmt.Errorf("invalid result of blame: %s", res) + } + return repo.GetCommit(string(res[:40])) +} diff --git a/modules/git/repo_blob.go b/modules/git/repo_blob.go new file mode 100644 index 0000000000..a9445a1f7a --- /dev/null +++ b/modules/git/repo_blob.go @@ -0,0 +1,30 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +func (repo *Repository) getBlob(id SHA1) (*Blob, error) { + if _, err := NewCommand("cat-file", "-p", id.String()).RunInDir(repo.Path); err != nil { + return nil, ErrNotExist{id.String(), ""} + } + + return &Blob{ + repo: repo, + TreeEntry: &TreeEntry{ + ID: id, + ptree: &Tree{ + repo: repo, + }, + }, + }, nil +} + +// GetBlob finds the blob object in the repository. +func (repo *Repository) GetBlob(idStr string) (*Blob, error) { + id, err := NewIDFromString(idStr) + if err != nil { + return nil, err + } + return repo.getBlob(id) +} diff --git a/modules/git/repo_blob_test.go b/modules/git/repo_blob_test.go new file mode 100644 index 0000000000..074365f164 --- /dev/null +++ b/modules/git/repo_blob_test.go @@ -0,0 +1,66 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "fmt" + "io/ioutil" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRepository_GetBlob_Found(t *testing.T) { + repoPath := filepath.Join(testReposDir, "repo1_bare") + r, err := OpenRepository(repoPath) + assert.NoError(t, err) + + testCases := []struct { + OID string + Data []byte + }{ + {"e2129701f1a4d54dc44f03c93bca0a2aec7c5449", []byte("file1\n")}, + {"6c493ff740f9380390d5c9ddef4af18697ac9375", []byte("file2\n")}, + } + + for _, testCase := range testCases { + blob, err := r.GetBlob(testCase.OID) + assert.NoError(t, err) + + dataReader, err := blob.Data() + assert.NoError(t, err) + + data, err := ioutil.ReadAll(dataReader) + assert.NoError(t, err) + assert.Equal(t, testCase.Data, data) + } +} + +func TestRepository_GetBlob_NotExist(t *testing.T) { + repoPath := filepath.Join(testReposDir, "repo1_bare") + r, err := OpenRepository(repoPath) + assert.NoError(t, err) + + testCase := "0000000000000000000000000000000000000000" + testError := ErrNotExist{testCase, ""} + + blob, err := r.GetBlob(testCase) + assert.Nil(t, blob) + assert.EqualError(t, err, testError.Error()) +} + +func TestRepository_GetBlob_NoId(t *testing.T) { + repoPath := filepath.Join(testReposDir, "repo1_bare") + r, err := OpenRepository(repoPath) + assert.NoError(t, err) + + testCase := "" + testError := fmt.Errorf("Length must be 40: %s", testCase) + + blob, err := r.GetBlob(testCase) + assert.Nil(t, blob) + assert.EqualError(t, err, testError.Error()) +} diff --git a/modules/git/repo_branch.go b/modules/git/repo_branch.go new file mode 100644 index 0000000000..6414abbec5 --- /dev/null +++ b/modules/git/repo_branch.go @@ -0,0 +1,134 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "fmt" + "strings" + + "gopkg.in/src-d/go-git.v4" + "gopkg.in/src-d/go-git.v4/plumbing" +) + +// BranchPrefix base dir of the branch information file store on git +const BranchPrefix = "refs/heads/" + +// IsReferenceExist returns true if given reference exists in the repository. +func IsReferenceExist(repoPath, name string) bool { + _, err := NewCommand("show-ref", "--verify", name).RunInDir(repoPath) + return err == nil +} + +// IsBranchExist returns true if given branch exists in the repository. +func IsBranchExist(repoPath, name string) bool { + return IsReferenceExist(repoPath, BranchPrefix+name) +} + +// IsBranchExist returns true if given branch exists in current repository. +func (repo *Repository) IsBranchExist(name string) bool { + return IsBranchExist(repo.Path, name) +} + +// Branch represents a Git branch. +type Branch struct { + Name string + Path string +} + +// GetHEADBranch returns corresponding branch of HEAD. +func (repo *Repository) GetHEADBranch() (*Branch, error) { + stdout, err := NewCommand("symbolic-ref", "HEAD").RunInDir(repo.Path) + if err != nil { + return nil, err + } + stdout = strings.TrimSpace(stdout) + + if !strings.HasPrefix(stdout, BranchPrefix) { + return nil, fmt.Errorf("invalid HEAD branch: %v", stdout) + } + + return &Branch{ + Name: stdout[len(BranchPrefix):], + Path: stdout, + }, nil +} + +// SetDefaultBranch sets default branch of repository. +func (repo *Repository) SetDefaultBranch(name string) error { + _, err := NewCommand("symbolic-ref", "HEAD", BranchPrefix+name).RunInDir(repo.Path) + return err +} + +// GetBranches returns all branches of the repository. +func (repo *Repository) GetBranches() ([]string, error) { + r, err := git.PlainOpen(repo.Path) + if err != nil { + return nil, err + } + + branchIter, err := r.Branches() + if err != nil { + return nil, err + } + branches := make([]string, 0) + if err = branchIter.ForEach(func(branch *plumbing.Reference) error { + branches = append(branches, branch.Name().Short()) + return nil + }); err != nil { + return nil, err + } + + return branches, nil +} + +// DeleteBranchOptions Option(s) for delete branch +type DeleteBranchOptions struct { + Force bool +} + +// DeleteBranch delete a branch by name on repository. +func (repo *Repository) DeleteBranch(name string, opts DeleteBranchOptions) error { + cmd := NewCommand("branch") + + if opts.Force { + cmd.AddArguments("-D") + } else { + cmd.AddArguments("-d") + } + + cmd.AddArguments(name) + _, err := cmd.RunInDir(repo.Path) + + return err +} + +// CreateBranch create a new branch +func (repo *Repository) CreateBranch(branch, newBranch string) error { + cmd := NewCommand("branch") + cmd.AddArguments(branch, newBranch) + + _, err := cmd.RunInDir(repo.Path) + + return err +} + +// AddRemote adds a new remote to repository. +func (repo *Repository) AddRemote(name, url string, fetch bool) error { + cmd := NewCommand("remote", "add") + if fetch { + cmd.AddArguments("-f") + } + cmd.AddArguments(name, url) + + _, err := cmd.RunInDir(repo.Path) + return err +} + +// RemoveRemote removes a remote from repository. +func (repo *Repository) RemoveRemote(name string) error { + _, err := NewCommand("remote", "remove", name).RunInDir(repo.Path) + return err +} diff --git a/modules/git/repo_branch_test.go b/modules/git/repo_branch_test.go new file mode 100644 index 0000000000..08736d702e --- /dev/null +++ b/modules/git/repo_branch_test.go @@ -0,0 +1,39 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRepository_GetBranches(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := OpenRepository(bareRepo1Path) + assert.NoError(t, err) + + branches, err := bareRepo1.GetBranches() + + assert.NoError(t, err) + assert.Len(t, branches, 3) + assert.ElementsMatch(t, []string{"branch1", "branch2", "master"}, branches) +} + +func BenchmarkRepository_GetBranches(b *testing.B) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := OpenRepository(bareRepo1Path) + if err != nil { + b.Fatal(err) + } + + for i := 0; i < b.N; i++ { + _, err := bareRepo1.GetBranches() + if err != nil { + b.Fatal(err) + } + } +} diff --git a/modules/git/repo_commit.go b/modules/git/repo_commit.go new file mode 100644 index 0000000000..146619373d --- /dev/null +++ b/modules/git/repo_commit.go @@ -0,0 +1,388 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "bytes" + "container/list" + "strconv" + "strings" + + version "github.com/mcuadros/go-version" +) + +// GetRefCommitID returns the last commit ID string of given reference (branch or tag). +func (repo *Repository) GetRefCommitID(name string) (string, error) { + stdout, err := NewCommand("show-ref", "--verify", name).RunInDir(repo.Path) + if err != nil { + if strings.Contains(err.Error(), "not a valid ref") { + return "", ErrNotExist{name, ""} + } + return "", err + } + return strings.Split(stdout, " ")[0], nil +} + +// GetBranchCommitID returns last commit ID string of given branch. +func (repo *Repository) GetBranchCommitID(name string) (string, error) { + return repo.GetRefCommitID(BranchPrefix + name) +} + +// GetTagCommitID returns last commit ID string of given tag. +func (repo *Repository) GetTagCommitID(name string) (string, error) { + stdout, err := NewCommand("rev-list", "-n", "1", name).RunInDir(repo.Path) + if err != nil { + if strings.Contains(err.Error(), "unknown revision or path") { + return "", ErrNotExist{name, ""} + } + return "", err + } + return strings.TrimSpace(stdout), nil +} + +// parseCommitData parses commit information from the (uncompressed) raw +// data from the commit object. +// \n\n separate headers from message +func parseCommitData(data []byte) (*Commit, error) { + commit := new(Commit) + commit.parents = make([]SHA1, 0, 1) + // we now have the contents of the commit object. Let's investigate... + nextline := 0 +l: + for { + eol := bytes.IndexByte(data[nextline:], '\n') + switch { + case eol > 0: + line := data[nextline : nextline+eol] + spacepos := bytes.IndexByte(line, ' ') + reftype := line[:spacepos] + switch string(reftype) { + case "tree", "object": + id, err := NewIDFromString(string(line[spacepos+1:])) + if err != nil { + return nil, err + } + commit.Tree.ID = id + case "parent": + // A commit can have one or more parents + oid, err := NewIDFromString(string(line[spacepos+1:])) + if err != nil { + return nil, err + } + commit.parents = append(commit.parents, oid) + case "author", "tagger": + sig, err := newSignatureFromCommitline(line[spacepos+1:]) + if err != nil { + return nil, err + } + commit.Author = sig + case "committer": + sig, err := newSignatureFromCommitline(line[spacepos+1:]) + if err != nil { + return nil, err + } + commit.Committer = sig + case "gpgsig": + sig, err := newGPGSignatureFromCommitline(data, nextline+spacepos+1, false) + if err != nil { + return nil, err + } + commit.Signature = sig + } + nextline += eol + 1 + case eol == 0: + cm := string(data[nextline+1:]) + + // Tag GPG signatures are stored below the commit message + sigindex := strings.Index(cm, "-----BEGIN PGP SIGNATURE-----") + if sigindex != -1 { + sig, err := newGPGSignatureFromCommitline(data, (nextline+1)+sigindex, true) + if err == nil && sig != nil { + // remove signature from commit message + if sigindex == 0 { + cm = "" + } else { + cm = cm[:sigindex-1] + } + commit.Signature = sig + } + } + + commit.CommitMessage = cm + break l + default: + break l + } + } + return commit, nil +} + +func (repo *Repository) getCommit(id SHA1) (*Commit, error) { + c, ok := repo.commitCache.Get(id.String()) + if ok { + log("Hit cache: %s", id) + return c.(*Commit), nil + } + + data, err := NewCommand("cat-file", "-p", id.String()).RunInDirBytes(repo.Path) + if err != nil { + if strings.Contains(err.Error(), "fatal: Not a valid object name") { + return nil, ErrNotExist{id.String(), ""} + } + return nil, err + } + + commit, err := parseCommitData(data) + if err != nil { + return nil, err + } + commit.repo = repo + commit.ID = id + + data, err = NewCommand("name-rev", id.String()).RunInDirBytes(repo.Path) + if err != nil { + return nil, err + } + + // name-rev commitID output will be "COMMIT_ID master" or "COMMIT_ID master~12" + commit.Branch = strings.Split(strings.Split(string(data), " ")[1], "~")[0] + + repo.commitCache.Set(id.String(), commit) + return commit, nil +} + +// GetCommit returns commit object of by ID string. +func (repo *Repository) GetCommit(commitID string) (*Commit, error) { + if len(commitID) != 40 { + var err error + actualCommitID, err := NewCommand("rev-parse", commitID).RunInDir(repo.Path) + if err != nil { + if strings.Contains(err.Error(), "unknown revision or path") { + return nil, ErrNotExist{commitID, ""} + } + return nil, err + } + commitID = actualCommitID + } + id, err := NewIDFromString(commitID) + if err != nil { + return nil, err + } + + return repo.getCommit(id) +} + +// GetBranchCommit returns the last commit of given branch. +func (repo *Repository) GetBranchCommit(name string) (*Commit, error) { + commitID, err := repo.GetBranchCommitID(name) + if err != nil { + return nil, err + } + return repo.GetCommit(commitID) +} + +// GetTagCommit get the commit of the specific tag via name +func (repo *Repository) GetTagCommit(name string) (*Commit, error) { + commitID, err := repo.GetTagCommitID(name) + if err != nil { + return nil, err + } + return repo.GetCommit(commitID) +} + +func (repo *Repository) getCommitByPathWithID(id SHA1, relpath string) (*Commit, error) { + // File name starts with ':' must be escaped. + if relpath[0] == ':' { + relpath = `\` + relpath + } + + stdout, err := NewCommand("log", "-1", prettyLogFormat, id.String(), "--", relpath).RunInDir(repo.Path) + if err != nil { + return nil, err + } + + id, err = NewIDFromString(stdout) + if err != nil { + return nil, err + } + + return repo.getCommit(id) +} + +// GetCommitByPath returns the last commit of relative path. +func (repo *Repository) GetCommitByPath(relpath string) (*Commit, error) { + stdout, err := NewCommand("log", "-1", prettyLogFormat, "--", relpath).RunInDirBytes(repo.Path) + if err != nil { + return nil, err + } + + commits, err := repo.parsePrettyFormatLogToList(stdout) + if err != nil { + return nil, err + } + return commits.Front().Value.(*Commit), nil +} + +// CommitsRangeSize the default commits range size +var CommitsRangeSize = 50 + +func (repo *Repository) commitsByRange(id SHA1, page int) (*list.List, error) { + stdout, err := NewCommand("log", id.String(), "--skip="+strconv.Itoa((page-1)*CommitsRangeSize), + "--max-count="+strconv.Itoa(CommitsRangeSize), prettyLogFormat).RunInDirBytes(repo.Path) + if err != nil { + return nil, err + } + return repo.parsePrettyFormatLogToList(stdout) +} + +func (repo *Repository) searchCommits(id SHA1, keyword string, all bool) (*list.List, error) { + cmd := NewCommand("log", id.String(), "-100", "-i", "--grep="+keyword, prettyLogFormat) + if all { + cmd.AddArguments("--all") + } + stdout, err := cmd.RunInDirBytes(repo.Path) + if err != nil { + return nil, err + } + return repo.parsePrettyFormatLogToList(stdout) +} + +func (repo *Repository) getFilesChanged(id1 string, id2 string) ([]string, error) { + stdout, err := NewCommand("diff", "--name-only", id1, id2).RunInDirBytes(repo.Path) + if err != nil { + return nil, err + } + return strings.Split(string(stdout), "\n"), nil +} + +// FileCommitsCount return the number of files at a revison +func (repo *Repository) FileCommitsCount(revision, file string) (int64, error) { + return commitsCount(repo.Path, revision, file) +} + +// CommitsByFileAndRange return the commits according revison file and the page +func (repo *Repository) CommitsByFileAndRange(revision, file string, page int) (*list.List, error) { + stdout, err := NewCommand("log", revision, "--follow", "--skip="+strconv.Itoa((page-1)*50), + "--max-count="+strconv.Itoa(CommitsRangeSize), prettyLogFormat, "--", file).RunInDirBytes(repo.Path) + if err != nil { + return nil, err + } + return repo.parsePrettyFormatLogToList(stdout) +} + +// FilesCountBetween return the number of files changed between two commits +func (repo *Repository) FilesCountBetween(startCommitID, endCommitID string) (int, error) { + stdout, err := NewCommand("diff", "--name-only", startCommitID+"..."+endCommitID).RunInDir(repo.Path) + if err != nil { + return 0, err + } + return len(strings.Split(stdout, "\n")) - 1, nil +} + +// CommitsBetween returns a list that contains commits between [last, before). +func (repo *Repository) CommitsBetween(last *Commit, before *Commit) (*list.List, error) { + stdout, err := NewCommand("rev-list", before.ID.String()+"..."+last.ID.String()).RunInDirBytes(repo.Path) + if err != nil { + return nil, err + } + return repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout)) +} + +// CommitsBetweenIDs return commits between twoe commits +func (repo *Repository) CommitsBetweenIDs(last, before string) (*list.List, error) { + lastCommit, err := repo.GetCommit(last) + if err != nil { + return nil, err + } + beforeCommit, err := repo.GetCommit(before) + if err != nil { + return nil, err + } + return repo.CommitsBetween(lastCommit, beforeCommit) +} + +// CommitsCountBetween return numbers of commits between two commits +func (repo *Repository) CommitsCountBetween(start, end string) (int64, error) { + return commitsCount(repo.Path, start+"..."+end, "") +} + +// commitsBefore the limit is depth, not total number of returned commits. +func (repo *Repository) commitsBefore(id SHA1, limit int) (*list.List, error) { + cmd := NewCommand("log") + if limit > 0 { + cmd.AddArguments("-"+strconv.Itoa(limit), prettyLogFormat, id.String()) + } else { + cmd.AddArguments(prettyLogFormat, id.String()) + } + + stdout, err := cmd.RunInDirBytes(repo.Path) + if err != nil { + return nil, err + } + + formattedLog, err := repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout)) + if err != nil { + return nil, err + } + + commits := list.New() + for logEntry := formattedLog.Front(); logEntry != nil; logEntry = logEntry.Next() { + commit := logEntry.Value.(*Commit) + branches, err := repo.getBranches(commit, 2) + if err != nil { + return nil, err + } + + if len(branches) > 1 { + break + } + + commits.PushBack(commit) + } + + return commits, nil +} + +func (repo *Repository) getCommitsBefore(id SHA1) (*list.List, error) { + return repo.commitsBefore(id, 0) +} + +func (repo *Repository) getCommitsBeforeLimit(id SHA1, num int) (*list.List, error) { + return repo.commitsBefore(id, num) +} + +func (repo *Repository) getBranches(commit *Commit, limit int) ([]string, error) { + if version.Compare(gitVersion, "2.7.0", ">=") { + stdout, err := NewCommand("for-each-ref", "--count="+strconv.Itoa(limit), "--format=%(refname:strip=2)", "--contains", commit.ID.String(), BranchPrefix).RunInDir(repo.Path) + if err != nil { + return nil, err + } + + branches := strings.Fields(stdout) + return branches, nil + } + + stdout, err := NewCommand("branch", "--contains", commit.ID.String()).RunInDir(repo.Path) + if err != nil { + return nil, err + } + + refs := strings.Split(stdout, "\n") + + var max int + if len(refs) > limit { + max = limit + } else { + max = len(refs) - 1 + } + + branches := make([]string, max) + for i, ref := range refs[:max] { + parts := strings.Fields(ref) + + branches[i] = parts[len(parts)-1] + } + return branches, nil +} diff --git a/modules/git/repo_commit_test.go b/modules/git/repo_commit_test.go new file mode 100644 index 0000000000..6761a45b7a --- /dev/null +++ b/modules/git/repo_commit_test.go @@ -0,0 +1,59 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRepository_GetCommitBranches(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := OpenRepository(bareRepo1Path) + assert.NoError(t, err) + + // these test case are specific to the repo1_bare test repo + testCases := []struct { + CommitID string + ExpectedBranches []string + }{ + {"2839944139e0de9737a044f78b0e4b40d989a9e3", []string{"branch1"}}, + {"5c80b0245c1c6f8343fa418ec374b13b5d4ee658", []string{"branch2"}}, + {"37991dec2c8e592043f47155ce4808d4580f9123", []string{"master"}}, + {"95bb4d39648ee7e325106df01a621c530863a653", []string{"branch1", "branch2"}}, + {"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", []string{"branch2", "master"}}, + {"master", []string{"master"}}, + } + for _, testCase := range testCases { + commit, err := bareRepo1.GetCommit(testCase.CommitID) + assert.NoError(t, err) + branches, err := bareRepo1.getBranches(commit, 2) + assert.NoError(t, err) + assert.Equal(t, testCase.ExpectedBranches, branches) + } +} + +func TestGetTagCommitWithSignature(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := OpenRepository(bareRepo1Path) + commit, err := bareRepo1.GetCommit("3ad28a9149a2864384548f3d17ed7f38014c9e8a") + + assert.NoError(t, err) + assert.NotNil(t, commit) + assert.NotNil(t, commit.Signature) + // test that signature is not in message + assert.Equal(t, "tag", commit.CommitMessage) +} + +func TestGetCommitWithBadCommitID(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := OpenRepository(bareRepo1Path) + commit, err := bareRepo1.GetCommit("bad_branch") + assert.Nil(t, commit) + assert.Error(t, err) + assert.EqualError(t, err, "object does not exist [id: bad_branch, rel_path: ]") +} diff --git a/modules/git/repo_hook.go b/modules/git/repo_hook.go new file mode 100644 index 0000000000..a652e938fa --- /dev/null +++ b/modules/git/repo_hook.go @@ -0,0 +1,15 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +// GetHook get one hook according the name on a repository +func (repo *Repository) GetHook(name string) (*Hook, error) { + return GetHook(repo.Path, name) +} + +// Hooks get all the hooks on the repository +func (repo *Repository) Hooks() ([]*Hook, error) { + return ListHooks(repo.Path) +} diff --git a/modules/git/repo_object.go b/modules/git/repo_object.go new file mode 100644 index 0000000000..3be8400d22 --- /dev/null +++ b/modules/git/repo_object.go @@ -0,0 +1,19 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +// ObjectType git object type +type ObjectType string + +const ( + // ObjectCommit commit object type + ObjectCommit ObjectType = "commit" + // ObjectTree tree object type + ObjectTree ObjectType = "tree" + // ObjectBlob blob object type + ObjectBlob ObjectType = "blob" + // ObjectTag tag object type + ObjectTag ObjectType = "tag" +) diff --git a/modules/git/repo_pull.go b/modules/git/repo_pull.go new file mode 100644 index 0000000000..c6d97a6fd1 --- /dev/null +++ b/modules/git/repo_pull.go @@ -0,0 +1,89 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "bytes" + "container/list" + "fmt" + "io" + "strconv" + "strings" + "time" +) + +// PullRequestInfo represents needed information for a pull request. +type PullRequestInfo struct { + MergeBase string + Commits *list.List + NumFiles int +} + +// GetMergeBase checks and returns merge base of two branches. +func (repo *Repository) GetMergeBase(base, head string) (string, error) { + stdout, err := NewCommand("merge-base", base, head).RunInDir(repo.Path) + return strings.TrimSpace(stdout), err +} + +// GetPullRequestInfo generates and returns pull request information +// between base and head branches of repositories. +func (repo *Repository) GetPullRequestInfo(basePath, baseBranch, headBranch string) (_ *PullRequestInfo, err error) { + var remoteBranch string + + // We don't need a temporary remote for same repository. + if repo.Path != basePath { + // Add a temporary remote + tmpRemote := strconv.FormatInt(time.Now().UnixNano(), 10) + if err = repo.AddRemote(tmpRemote, basePath, true); err != nil { + return nil, fmt.Errorf("AddRemote: %v", err) + } + defer repo.RemoveRemote(tmpRemote) + + remoteBranch = "remotes/" + tmpRemote + "/" + baseBranch + } else { + remoteBranch = baseBranch + } + + prInfo := new(PullRequestInfo) + prInfo.MergeBase, err = repo.GetMergeBase(remoteBranch, headBranch) + if err != nil { + return nil, fmt.Errorf("GetMergeBase: %v", err) + } + + logs, err := NewCommand("log", prInfo.MergeBase+"..."+headBranch, prettyLogFormat).RunInDirBytes(repo.Path) + if err != nil { + return nil, err + } + prInfo.Commits, err = repo.parsePrettyFormatLogToList(logs) + if err != nil { + return nil, fmt.Errorf("parsePrettyFormatLogToList: %v", err) + } + + // Count number of changed files. + stdout, err := NewCommand("diff", "--name-only", remoteBranch+"..."+headBranch).RunInDir(repo.Path) + if err != nil { + return nil, err + } + prInfo.NumFiles = len(strings.Split(stdout, "\n")) - 1 + + return prInfo, nil +} + +// GetPatch generates and returns patch data between given revisions. +func (repo *Repository) GetPatch(base, head string) ([]byte, error) { + return NewCommand("diff", "-p", "--binary", base, head).RunInDirBytes(repo.Path) +} + +// GetFormatPatch generates and returns format-patch data between given revisions. +func (repo *Repository) GetFormatPatch(base, head string) (io.Reader, error) { + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + + if err := NewCommand("format-patch", "--binary", "--stdout", base+"..."+head). + RunInDirPipeline(repo.Path, stdout, stderr); err != nil { + return nil, concatenateError(err, stderr.String()) + } + return stdout, nil +} diff --git a/modules/git/repo_pull_test.go b/modules/git/repo_pull_test.go new file mode 100644 index 0000000000..e194788773 --- /dev/null +++ b/modules/git/repo_pull_test.go @@ -0,0 +1,30 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetFormatPatch(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + clonedPath, err := cloneRepo(bareRepo1Path, testReposDir, "repo1_TestGetFormatPatch") + assert.NoError(t, err) + defer os.RemoveAll(clonedPath) + repo, err := OpenRepository(clonedPath) + assert.NoError(t, err) + rd, err := repo.GetFormatPatch("8d92fc95^", "8d92fc95") + assert.NoError(t, err) + patchb, err := ioutil.ReadAll(rd) + assert.NoError(t, err) + patch := string(patchb) + assert.Regexp(t, "^From 8d92fc95", patch) + assert.Contains(t, patch, "Subject: [PATCH] Add file2.txt") +} diff --git a/modules/git/repo_ref.go b/modules/git/repo_ref.go new file mode 100644 index 0000000000..e1ab46e090 --- /dev/null +++ b/modules/git/repo_ref.go @@ -0,0 +1,51 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "strings" + + "gopkg.in/src-d/go-git.v4" + "gopkg.in/src-d/go-git.v4/plumbing" +) + +// GetRefs returns all references of the repository. +func (repo *Repository) GetRefs() ([]*Reference, error) { + return repo.GetRefsFiltered("") +} + +// GetRefsFiltered returns all references of the repository that matches patterm exactly or starting with. +func (repo *Repository) GetRefsFiltered(pattern string) ([]*Reference, error) { + r, err := git.PlainOpen(repo.Path) + if err != nil { + return nil, err + } + + refsIter, err := r.References() + if err != nil { + return nil, err + } + refs := make([]*Reference, 0) + if err = refsIter.ForEach(func(ref *plumbing.Reference) error { + if ref.Name() != plumbing.HEAD && !ref.Name().IsRemote() && + (pattern == "" || strings.HasPrefix(ref.Name().String(), pattern)) { + r := &Reference{ + Name: ref.Name().String(), + Object: SHA1(ref.Hash()), + Type: string(ObjectCommit), + repo: repo, + } + if ref.Name().IsTag() { + r.Type = string(ObjectTag) + } + refs = append(refs, r) + } + return nil + }); err != nil { + return nil, err + } + + return refs, nil +} diff --git a/modules/git/repo_ref_test.go b/modules/git/repo_ref_test.go new file mode 100644 index 0000000000..2a3ea26a76 --- /dev/null +++ b/modules/git/repo_ref_test.go @@ -0,0 +1,49 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRepository_GetRefs(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := OpenRepository(bareRepo1Path) + assert.NoError(t, err) + + refs, err := bareRepo1.GetRefs() + + assert.NoError(t, err) + assert.Len(t, refs, 4) + + expectedRefs := []string{ + BranchPrefix + "branch1", + BranchPrefix + "branch2", + BranchPrefix + "master", + TagPrefix + "test", + } + + for _, ref := range refs { + assert.Contains(t, expectedRefs, ref.Name) + } +} + +func TestRepository_GetRefsFiltered(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := OpenRepository(bareRepo1Path) + assert.NoError(t, err) + + refs, err := bareRepo1.GetRefsFiltered(TagPrefix) + + assert.NoError(t, err) + if assert.Len(t, refs, 1) { + assert.Equal(t, TagPrefix+"test", refs[0].Name) + assert.Equal(t, "tag", refs[0].Type) + assert.Equal(t, "3ad28a9149a2864384548f3d17ed7f38014c9e8a", refs[0].Object.String()) + } +} diff --git a/modules/git/repo_tag.go b/modules/git/repo_tag.go new file mode 100644 index 0000000000..84825d7dc3 --- /dev/null +++ b/modules/git/repo_tag.go @@ -0,0 +1,149 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "strings" + + "github.com/mcuadros/go-version" +) + +// TagPrefix tags prefix path on the repository +const TagPrefix = "refs/tags/" + +// IsTagExist returns true if given tag exists in the repository. +func IsTagExist(repoPath, name string) bool { + return IsReferenceExist(repoPath, TagPrefix+name) +} + +// IsTagExist returns true if given tag exists in the repository. +func (repo *Repository) IsTagExist(name string) bool { + return IsTagExist(repo.Path, name) +} + +// CreateTag create one tag in the repository +func (repo *Repository) CreateTag(name, revision string) error { + _, err := NewCommand("tag", name, revision).RunInDir(repo.Path) + return err +} + +func (repo *Repository) getTag(id SHA1) (*Tag, error) { + t, ok := repo.tagCache.Get(id.String()) + if ok { + log("Hit cache: %s", id) + return t.(*Tag), nil + } + + // Get tag type + tp, err := NewCommand("cat-file", "-t", id.String()).RunInDir(repo.Path) + if err != nil { + return nil, err + } + tp = strings.TrimSpace(tp) + + // Tag is a commit. + if ObjectType(tp) == ObjectCommit { + tag := &Tag{ + ID: id, + Object: id, + Type: string(ObjectCommit), + repo: repo, + } + + repo.tagCache.Set(id.String(), tag) + return tag, nil + } + + // Tag with message. + data, err := NewCommand("cat-file", "-p", id.String()).RunInDirBytes(repo.Path) + if err != nil { + return nil, err + } + + tag, err := parseTagData(data) + if err != nil { + return nil, err + } + + tag.ID = id + tag.repo = repo + + repo.tagCache.Set(id.String(), tag) + return tag, nil +} + +// GetTag returns a Git tag by given name. +func (repo *Repository) GetTag(name string) (*Tag, error) { + idStr, err := repo.GetTagCommitID(name) + if err != nil { + return nil, err + } + + id, err := NewIDFromString(idStr) + if err != nil { + return nil, err + } + + tag, err := repo.getTag(id) + if err != nil { + return nil, err + } + tag.Name = name + return tag, nil +} + +// GetTagInfos returns all tag infos of the repository. +func (repo *Repository) GetTagInfos() ([]*Tag, error) { + // TODO this a slow implementation, makes one git command per tag + stdout, err := NewCommand("tag").RunInDir(repo.Path) + if err != nil { + return nil, err + } + + tagNames := strings.Split(stdout, "\n") + var tags = make([]*Tag, 0, len(tagNames)) + for _, tagName := range tagNames { + tagName = strings.TrimSpace(tagName) + if len(tagName) == 0 { + continue + } + + tag, err := repo.GetTag(tagName) + if err != nil { + return nil, err + } + tags = append(tags, tag) + } + sortTagsByTime(tags) + return tags, nil +} + +// GetTags returns all tags of the repository. +func (repo *Repository) GetTags() ([]string, error) { + cmd := NewCommand("tag", "-l") + if version.Compare(gitVersion, "2.0.0", ">=") { + cmd.AddArguments("--sort=-v:refname") + } + + stdout, err := cmd.RunInDir(repo.Path) + if err != nil { + return nil, err + } + + tags := strings.Split(stdout, "\n") + tags = tags[:len(tags)-1] + + if version.Compare(gitVersion, "2.0.0", "<") { + version.Sort(tags) + + // Reverse order + for i := 0; i < len(tags)/2; i++ { + j := len(tags) - i - 1 + tags[i], tags[j] = tags[j], tags[i] + } + } + + return tags, nil +} diff --git a/modules/git/repo_tag_test.go b/modules/git/repo_tag_test.go new file mode 100644 index 0000000000..ccb2d57ac2 --- /dev/null +++ b/modules/git/repo_tag_test.go @@ -0,0 +1,44 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRepository_GetTags(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := OpenRepository(bareRepo1Path) + assert.NoError(t, err) + + tags, err := bareRepo1.GetTagInfos() + assert.NoError(t, err) + assert.Len(t, tags, 1) + assert.EqualValues(t, "test", tags[0].Name) + assert.EqualValues(t, "37991dec2c8e592043f47155ce4808d4580f9123", tags[0].ID.String()) + assert.EqualValues(t, "commit", tags[0].Type) +} + +func TestRepository_GetTag(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + + clonedPath, err := cloneRepo(bareRepo1Path, testReposDir, "repo1_TestRepository_GetTag") + assert.NoError(t, err) + defer os.RemoveAll(clonedPath) + + bareRepo1, err := OpenRepository(clonedPath) + assert.NoError(t, err) + + tag, err := bareRepo1.GetTag("test") + assert.NoError(t, err) + assert.NotNil(t, tag) + assert.EqualValues(t, "test", tag.Name) + assert.EqualValues(t, "37991dec2c8e592043f47155ce4808d4580f9123", tag.ID.String()) + assert.EqualValues(t, "commit", tag.Type) +} diff --git a/modules/git/repo_test.go b/modules/git/repo_test.go new file mode 100644 index 0000000000..c5ce6f4447 --- /dev/null +++ b/modules/git/repo_test.go @@ -0,0 +1,26 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestGetLatestCommitTime(t *testing.T) { + lct, err := GetLatestCommitTime(".") + assert.NoError(t, err) + // Time is in the past + now := time.Now() + assert.True(t, lct.Unix() < now.Unix(), "%d not smaller than %d", lct, now) + // Time is after Mon Oct 23 03:52:09 2017 +0300 + // which is the time of commit + // d47b98c44c9a6472e44ab80efe65235e11c6da2a + refTime, err := time.Parse("Mon Jan 02 15:04:05 2006 -0700", "Mon Oct 23 03:52:09 2017 +0300") + assert.NoError(t, err) + assert.True(t, lct.Unix() > refTime.Unix(), "%d not greater than %d", lct, refTime) +} diff --git a/modules/git/repo_tree.go b/modules/git/repo_tree.go new file mode 100644 index 0000000000..3fa491d529 --- /dev/null +++ b/modules/git/repo_tree.go @@ -0,0 +1,35 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +func (repo *Repository) getTree(id SHA1) (*Tree, error) { + treePath := filepathFromSHA1(repo.Path, id.String()) + if isFile(treePath) { + _, err := NewCommand("ls-tree", id.String()).RunInDir(repo.Path) + if err != nil { + return nil, ErrNotExist{id.String(), ""} + } + } + + return NewTree(repo, id), nil +} + +// GetTree find the tree object in the repository. +func (repo *Repository) GetTree(idStr string) (*Tree, error) { + if len(idStr) != 40 { + res, err := NewCommand("rev-parse", idStr).RunInDir(repo.Path) + if err != nil { + return nil, err + } + if len(res) > 0 { + idStr = res[:len(res)-1] + } + } + id, err := NewIDFromString(idStr) + if err != nil { + return nil, err + } + return repo.getTree(id) +} diff --git a/modules/git/sha1.go b/modules/git/sha1.go new file mode 100644 index 0000000000..6c9d53949d --- /dev/null +++ b/modules/git/sha1.go @@ -0,0 +1,76 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "bytes" + "encoding/hex" + "fmt" + "strings" +) + +// EmptySHA defines empty git SHA +const EmptySHA = "0000000000000000000000000000000000000000" + +// SHA1 a git commit name +type SHA1 [20]byte + +// Equal returns true if s has the same SHA1 as caller. +// Support 40-length-string, []byte, SHA1. +func (id SHA1) Equal(s2 interface{}) bool { + switch v := s2.(type) { + case string: + if len(v) != 40 { + return false + } + return v == id.String() + case []byte: + return bytes.Equal(v, id[:]) + case SHA1: + return v == id + default: + return false + } +} + +// String returns string (hex) representation of the Oid. +func (id SHA1) String() string { + return hex.EncodeToString(id[:]) +} + +// MustID always creates a new SHA1 from a [20]byte array with no validation of input. +func MustID(b []byte) SHA1 { + var id SHA1 + copy(id[:], b) + return id +} + +// NewID creates a new SHA1 from a [20]byte array. +func NewID(b []byte) (SHA1, error) { + if len(b) != 20 { + return SHA1{}, fmt.Errorf("Length must be 20: %v", b) + } + return MustID(b), nil +} + +// MustIDFromString always creates a new sha from a ID with no validation of input. +func MustIDFromString(s string) SHA1 { + b, _ := hex.DecodeString(s) + return MustID(b) +} + +// NewIDFromString creates a new SHA1 from a ID string of length 40. +func NewIDFromString(s string) (SHA1, error) { + var id SHA1 + s = strings.TrimSpace(s) + if len(s) != 40 { + return id, fmt.Errorf("Length must be 40: %s", s) + } + b, err := hex.DecodeString(s) + if err != nil { + return id, err + } + return NewID(b) +} diff --git a/modules/git/signature.go b/modules/git/signature.go new file mode 100644 index 0000000000..e6ab247fd7 --- /dev/null +++ b/modules/git/signature.go @@ -0,0 +1,58 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "bytes" + "strconv" + "time" +) + +// Signature represents the Author or Committer information. +type Signature struct { + Email string + Name string + When time.Time +} + +const ( + // GitTimeLayout is the (default) time layout used by git. + GitTimeLayout = "Mon Jan _2 15:04:05 2006 -0700" +) + +// Helper to get a signature from the commit line, which looks like these: +// author Patrick Gundlach <gundlach@speedata.de> 1378823654 +0200 +// author Patrick Gundlach <gundlach@speedata.de> Thu, 07 Apr 2005 22:13:13 +0200 +// but without the "author " at the beginning (this method should) +// be used for author and committer. +// +// FIXME: include timezone for timestamp! +func newSignatureFromCommitline(line []byte) (_ *Signature, err error) { + sig := new(Signature) + emailStart := bytes.IndexByte(line, '<') + sig.Name = string(line[:emailStart-1]) + emailEnd := bytes.IndexByte(line, '>') + sig.Email = string(line[emailStart+1 : emailEnd]) + + // Check date format. + if len(line) > emailEnd+2 { + firstChar := line[emailEnd+2] + if firstChar >= 48 && firstChar <= 57 { + timestop := bytes.IndexByte(line[emailEnd+2:], ' ') + timestring := string(line[emailEnd+2 : emailEnd+2+timestop]) + seconds, _ := strconv.ParseInt(timestring, 10, 64) + sig.When = time.Unix(seconds, 0) + } else { + sig.When, err = time.Parse(GitTimeLayout, string(line[emailEnd+2:])) + if err != nil { + return nil, err + } + } + } else { + // Fall back to unix 0 time + sig.When = time.Unix(0, 0) + } + return sig, nil +} diff --git a/modules/git/submodule.go b/modules/git/submodule.go new file mode 100644 index 0000000000..294df3986a --- /dev/null +++ b/modules/git/submodule.go @@ -0,0 +1,87 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import "strings" + +// SubModule submodule is a reference on git repository +type SubModule struct { + Name string + URL string +} + +// SubModuleFile represents a file with submodule type. +type SubModuleFile struct { + *Commit + + refURL string + refID string +} + +// NewSubModuleFile create a new submodule file +func NewSubModuleFile(c *Commit, refURL, refID string) *SubModuleFile { + return &SubModuleFile{ + Commit: c, + refURL: refURL, + refID: refID, + } +} + +func getRefURL(refURL, urlPrefix, parentPath string) string { + if refURL == "" { + return "" + } + + url := strings.TrimSuffix(refURL, ".git") + + // git://xxx/user/repo + if strings.HasPrefix(url, "git://") { + return "http://" + strings.TrimPrefix(url, "git://") + } + + // http[s]://xxx/user/repo + if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { + return url + } + + // Relative url prefix check (according to git submodule documentation) + if strings.HasPrefix(url, "./") || strings.HasPrefix(url, "../") { + // ...construct and return correct submodule url here... + idx := strings.Index(parentPath, "/src/") + if idx == -1 { + return url + } + return strings.TrimSuffix(urlPrefix, "/") + parentPath[:idx] + "/" + url + } + + // sysuser@xxx:user/repo + i := strings.Index(url, "@") + j := strings.LastIndex(url, ":") + + // Only process when i < j because git+ssh://git@git.forwardbias.in/npploader.git + if i > -1 && j > -1 && i < j { + // fix problem with reverse proxy works only with local server + if strings.Contains(urlPrefix, url[i+1:j]) { + return urlPrefix + url[j+1:] + } + if strings.HasPrefix(url, "ssh://") || strings.HasPrefix(url, "git+ssh://") { + k := strings.Index(url[j+1:], "/") + return "http://" + url[i+1:j] + "/" + url[j+1:][k+1:] + } + return "http://" + url[i+1:j] + "/" + url[j+1:] + } + + return url +} + +// RefURL guesses and returns reference URL. +func (sf *SubModuleFile) RefURL(urlPrefix string, parentPath string) string { + return getRefURL(sf.refURL, urlPrefix, parentPath) +} + +// RefID returns reference ID. +func (sf *SubModuleFile) RefID() string { + return sf.refID +} diff --git a/modules/git/submodule_test.go b/modules/git/submodule_test.go new file mode 100644 index 0000000000..6a3bb7ec7b --- /dev/null +++ b/modules/git/submodule_test.go @@ -0,0 +1,29 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetRefURL(t *testing.T) { + var kases = []struct { + refURL string + prefixURL string + parentPath string + expect string + }{ + {"git://github.com/user1/repo1", "/", "/", "http://github.com/user1/repo1"}, + {"https://localhost/user1/repo1.git", "/", "/", "https://localhost/user1/repo1"}, + {"git@github.com/user1/repo1.git", "/", "/", "git@github.com/user1/repo1"}, + {"ssh://git@git.zefie.net:2222/zefie/lge_g6_kernel_scripts.git", "/", "/", "http://git.zefie.net/zefie/lge_g6_kernel_scripts"}, + } + + for _, kase := range kases { + assert.EqualValues(t, kase.expect, getRefURL(kase.refURL, kase.prefixURL, kase.parentPath)) + } +} diff --git a/modules/git/tag.go b/modules/git/tag.go new file mode 100644 index 0000000000..500fd27491 --- /dev/null +++ b/modules/git/tag.go @@ -0,0 +1,89 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "bytes" + "sort" +) + +// Tag represents a Git tag. +type Tag struct { + Name string + ID SHA1 + repo *Repository + Object SHA1 // The id of this commit object + Type string + Tagger *Signature + Message string +} + +// Commit return the commit of the tag reference +func (tag *Tag) Commit() (*Commit, error) { + return tag.repo.getCommit(tag.Object) +} + +// Parse commit information from the (uncompressed) raw +// data from the commit object. +// \n\n separate headers from message +func parseTagData(data []byte) (*Tag, error) { + tag := new(Tag) + // we now have the contents of the commit object. Let's investigate... + nextline := 0 +l: + for { + eol := bytes.IndexByte(data[nextline:], '\n') + switch { + case eol > 0: + line := data[nextline : nextline+eol] + spacepos := bytes.IndexByte(line, ' ') + reftype := line[:spacepos] + switch string(reftype) { + case "object": + id, err := NewIDFromString(string(line[spacepos+1:])) + if err != nil { + return nil, err + } + tag.Object = id + case "type": + // A commit can have one or more parents + tag.Type = string(line[spacepos+1:]) + case "tagger": + sig, err := newSignatureFromCommitline(line[spacepos+1:]) + if err != nil { + return nil, err + } + tag.Tagger = sig + } + nextline += eol + 1 + case eol == 0: + tag.Message = string(data[nextline+1:]) + break l + default: + break l + } + } + return tag, nil +} + +type tagSorter []*Tag + +func (ts tagSorter) Len() int { + return len([]*Tag(ts)) +} + +func (ts tagSorter) Less(i, j int) bool { + return []*Tag(ts)[i].Tagger.When.After([]*Tag(ts)[j].Tagger.When) +} + +func (ts tagSorter) Swap(i, j int) { + []*Tag(ts)[i], []*Tag(ts)[j] = []*Tag(ts)[j], []*Tag(ts)[i] +} + +// sortTagsByTime +func sortTagsByTime(tags []*Tag) { + sorter := tagSorter(tags) + sort.Sort(sorter) +} diff --git a/modules/git/tests/repos/repo1_bare/HEAD b/modules/git/tests/repos/repo1_bare/HEAD new file mode 100644 index 0000000000..cb089cd89a --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/modules/git/tests/repos/repo1_bare/config b/modules/git/tests/repos/repo1_bare/config new file mode 100644 index 0000000000..07d359d07c --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/config @@ -0,0 +1,4 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true diff --git a/modules/git/tests/repos/repo1_bare/description b/modules/git/tests/repos/repo1_bare/description new file mode 100644 index 0000000000..498b267a8c --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/modules/git/tests/repos/repo1_bare/hooks/applypatch-msg.sample b/modules/git/tests/repos/repo1_bare/hooks/applypatch-msg.sample new file mode 100755 index 0000000000..a5d7b84a67 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/hooks/applypatch-msg.sample @@ -0,0 +1,15 @@ +#!/bin/sh +# +# An example hook script to check the commit log message taken by +# applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. The hook is +# allowed to edit the commit message file. +# +# To enable this hook, rename this file to "applypatch-msg". + +. git-sh-setup +commitmsg="$(git rev-parse --git-path hooks/commit-msg)" +test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} +: diff --git a/modules/git/tests/repos/repo1_bare/hooks/commit-msg.sample b/modules/git/tests/repos/repo1_bare/hooks/commit-msg.sample new file mode 100755 index 0000000000..b58d1184a9 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/hooks/commit-msg.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to check the commit log message. +# Called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero +# status after issuing an appropriate message if it wants to stop the +# commit. The hook is allowed to edit the commit message file. +# +# To enable this hook, rename this file to "commit-msg". + +# Uncomment the below to add a Signed-off-by line to the message. +# Doing this in a hook is a bad idea in general, but the prepare-commit-msg +# hook is more suited to it. +# +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" + +# This example catches duplicate Signed-off-by lines. + +test "" = "$(grep '^Signed-off-by: ' "$1" | + sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { + echo >&2 Duplicate Signed-off-by lines. + exit 1 +} diff --git a/modules/git/tests/repos/repo1_bare/hooks/post-update.sample b/modules/git/tests/repos/repo1_bare/hooks/post-update.sample new file mode 100755 index 0000000000..ec17ec1939 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/hooks/post-update.sample @@ -0,0 +1,8 @@ +#!/bin/sh +# +# An example hook script to prepare a packed repository for use over +# dumb transports. +# +# To enable this hook, rename this file to "post-update". + +exec git update-server-info diff --git a/modules/git/tests/repos/repo1_bare/hooks/pre-applypatch.sample b/modules/git/tests/repos/repo1_bare/hooks/pre-applypatch.sample new file mode 100755 index 0000000000..4142082bcb --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/hooks/pre-applypatch.sample @@ -0,0 +1,14 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed +# by applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-applypatch". + +. git-sh-setup +precommit="$(git rev-parse --git-path hooks/pre-commit)" +test -x "$precommit" && exec "$precommit" ${1+"$@"} +: diff --git a/modules/git/tests/repos/repo1_bare/hooks/pre-commit.sample b/modules/git/tests/repos/repo1_bare/hooks/pre-commit.sample new file mode 100755 index 0000000000..68d62d5446 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/hooks/pre-commit.sample @@ -0,0 +1,49 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=4b825dc642cb6eb9a060e54bf8d69288fbee4904 +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# If there are whitespace errors, print the offending file names and fail. +exec git diff-index --check --cached $against -- diff --git a/modules/git/tests/repos/repo1_bare/hooks/pre-push.sample b/modules/git/tests/repos/repo1_bare/hooks/pre-push.sample new file mode 100755 index 0000000000..6187dbf439 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/hooks/pre-push.sample @@ -0,0 +1,53 @@ +#!/bin/sh + +# An example hook script to verify what is about to be pushed. Called by "git +# push" after it has checked the remote status, but before anything has been +# pushed. If this script exits with a non-zero status nothing will be pushed. +# +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# <local ref> <local sha1> <remote ref> <remote sha1> +# +# This sample shows how to prevent push of commits where the log message starts +# with "WIP" (work in progress). + +remote="$1" +url="$2" + +z40=0000000000000000000000000000000000000000 + +while read local_ref local_sha remote_ref remote_sha +do + if [ "$local_sha" = $z40 ] + then + # Handle delete + : + else + if [ "$remote_sha" = $z40 ] + then + # New branch, examine all commits + range="$local_sha" + else + # Update to existing branch, examine new commits + range="$remote_sha..$local_sha" + fi + + # Check for WIP commit + commit=`git rev-list -n 1 --grep '^WIP' "$range"` + if [ -n "$commit" ] + then + echo >&2 "Found WIP commit in $local_ref, not pushing" + exit 1 + fi + fi +done + +exit 0 diff --git a/modules/git/tests/repos/repo1_bare/hooks/pre-rebase.sample b/modules/git/tests/repos/repo1_bare/hooks/pre-rebase.sample new file mode 100755 index 0000000000..33730ca647 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/hooks/pre-rebase.sample @@ -0,0 +1,169 @@ +#!/bin/sh +# +# Copyright (c) 2006, 2008 Junio C Hamano +# +# The "pre-rebase" hook is run just before "git rebase" starts doing +# its job, and can prevent the command from running by exiting with +# non-zero status. +# +# The hook is called with the following parameters: +# +# $1 -- the upstream the series was forked from. +# $2 -- the branch being rebased (or empty when rebasing the current branch). +# +# This sample shows how to prevent topic branches that are already +# merged to 'next' branch from getting rebased, because allowing it +# would result in rebasing already published history. + +publish=next +basebranch="$1" +if test "$#" = 2 +then + topic="refs/heads/$2" +else + topic=`git symbolic-ref HEAD` || + exit 0 ;# we do not interrupt rebasing detached HEAD +fi + +case "$topic" in +refs/heads/??/*) + ;; +*) + exit 0 ;# we do not interrupt others. + ;; +esac + +# Now we are dealing with a topic branch being rebased +# on top of master. Is it OK to rebase it? + +# Does the topic really exist? +git show-ref -q "$topic" || { + echo >&2 "No such branch $topic" + exit 1 +} + +# Is topic fully merged to master? +not_in_master=`git rev-list --pretty=oneline ^master "$topic"` +if test -z "$not_in_master" +then + echo >&2 "$topic is fully merged to master; better remove it." + exit 1 ;# we could allow it, but there is no point. +fi + +# Is topic ever merged to next? If so you should not be rebasing it. +only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` +only_next_2=`git rev-list ^master ${publish} | sort` +if test "$only_next_1" = "$only_next_2" +then + not_in_topic=`git rev-list "^$topic" master` + if test -z "$not_in_topic" + then + echo >&2 "$topic is already up-to-date with master" + exit 1 ;# we could allow it, but there is no point. + else + exit 0 + fi +else + not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` + /usr/bin/perl -e ' + my $topic = $ARGV[0]; + my $msg = "* $topic has commits already merged to public branch:\n"; + my (%not_in_next) = map { + /^([0-9a-f]+) /; + ($1 => 1); + } split(/\n/, $ARGV[1]); + for my $elem (map { + /^([0-9a-f]+) (.*)$/; + [$1 => $2]; + } split(/\n/, $ARGV[2])) { + if (!exists $not_in_next{$elem->[0]}) { + if ($msg) { + print STDERR $msg; + undef $msg; + } + print STDERR " $elem->[1]\n"; + } + } + ' "$topic" "$not_in_next" "$not_in_master" + exit 1 +fi + +<<\DOC_END + +This sample hook safeguards topic branches that have been +published from being rewound. + +The workflow assumed here is: + + * Once a topic branch forks from "master", "master" is never + merged into it again (either directly or indirectly). + + * Once a topic branch is fully cooked and merged into "master", + it is deleted. If you need to build on top of it to correct + earlier mistakes, a new topic branch is created by forking at + the tip of the "master". This is not strictly necessary, but + it makes it easier to keep your history simple. + + * Whenever you need to test or publish your changes to topic + branches, merge them into "next" branch. + +The script, being an example, hardcodes the publish branch name +to be "next", but it is trivial to make it configurable via +$GIT_DIR/config mechanism. + +With this workflow, you would want to know: + +(1) ... if a topic branch has ever been merged to "next". Young + topic branches can have stupid mistakes you would rather + clean up before publishing, and things that have not been + merged into other branches can be easily rebased without + affecting other people. But once it is published, you would + not want to rewind it. + +(2) ... if a topic branch has been fully merged to "master". + Then you can delete it. More importantly, you should not + build on top of it -- other people may already want to + change things related to the topic as patches against your + "master", so if you need further changes, it is better to + fork the topic (perhaps with the same name) afresh from the + tip of "master". + +Let's look at this example: + + o---o---o---o---o---o---o---o---o---o "next" + / / / / + / a---a---b A / / + / / / / + / / c---c---c---c B / + / / / \ / + / / / b---b C \ / + / / / / \ / + ---o---o---o---o---o---o---o---o---o---o---o "master" + + +A, B and C are topic branches. + + * A has one fix since it was merged up to "next". + + * B has finished. It has been fully merged up to "master" and "next", + and is ready to be deleted. + + * C has not merged to "next" at all. + +We would want to allow C to be rebased, refuse A, and encourage +B to be deleted. + +To compute (1): + + git rev-list ^master ^topic next + git rev-list ^master next + + if these match, topic has not merged in next at all. + +To compute (2): + + git rev-list master..topic + + if this is empty, it is fully merged to "master". + +DOC_END diff --git a/modules/git/tests/repos/repo1_bare/hooks/pre-receive.sample b/modules/git/tests/repos/repo1_bare/hooks/pre-receive.sample new file mode 100755 index 0000000000..a1fd29ec14 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/hooks/pre-receive.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to make use of push options. +# The example simply echoes all push options that start with 'echoback=' +# and rejects all pushes when the "reject" push option is used. +# +# To enable this hook, rename this file to "pre-receive". + +if test -n "$GIT_PUSH_OPTION_COUNT" +then + i=0 + while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" + do + eval "value=\$GIT_PUSH_OPTION_$i" + case "$value" in + echoback=*) + echo "echo from the pre-receive-hook: ${value#*=}" >&2 + ;; + reject) + exit 1 + esac + i=$((i + 1)) + done +fi diff --git a/modules/git/tests/repos/repo1_bare/hooks/prepare-commit-msg.sample b/modules/git/tests/repos/repo1_bare/hooks/prepare-commit-msg.sample new file mode 100755 index 0000000000..f093a02ec4 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/hooks/prepare-commit-msg.sample @@ -0,0 +1,36 @@ +#!/bin/sh +# +# An example hook script to prepare the commit log message. +# Called by "git commit" with the name of the file that has the +# commit message, followed by the description of the commit +# message's source. The hook's purpose is to edit the commit +# message file. If the hook fails with a non-zero status, +# the commit is aborted. +# +# To enable this hook, rename this file to "prepare-commit-msg". + +# This hook includes three examples. The first comments out the +# "Conflicts:" part of a merge commit. +# +# The second includes the output of "git diff --name-status -r" +# into the message, just before the "git status" output. It is +# commented because it doesn't cope with --amend or with squashed +# commits. +# +# The third example adds a Signed-off-by line to the message, that can +# still be edited. This is rarely a good idea. + +case "$2,$3" in + merge,) + /usr/bin/perl -i.bak -ne 's/^/# /, s/^# #/#/ if /^Conflicts/ .. /#/; print' "$1" ;; + +# ,|template,) +# /usr/bin/perl -i.bak -pe ' +# print "\n" . `git diff --cached --name-status -r` +# if /^#/ && $first++ == 0' "$1" ;; + + *) ;; +esac + +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" diff --git a/modules/git/tests/repos/repo1_bare/hooks/update.sample b/modules/git/tests/repos/repo1_bare/hooks/update.sample new file mode 100755 index 0000000000..80ba94135c --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/hooks/update.sample @@ -0,0 +1,128 @@ +#!/bin/sh +# +# An example hook script to block unannotated tags from entering. +# Called by "git receive-pack" with arguments: refname sha1-old sha1-new +# +# To enable this hook, rename this file to "update". +# +# Config +# ------ +# hooks.allowunannotated +# This boolean sets whether unannotated tags will be allowed into the +# repository. By default they won't be. +# hooks.allowdeletetag +# This boolean sets whether deleting tags will be allowed in the +# repository. By default they won't be. +# hooks.allowmodifytag +# This boolean sets whether a tag may be modified after creation. By default +# it won't be. +# hooks.allowdeletebranch +# This boolean sets whether deleting branches will be allowed in the +# repository. By default they won't be. +# hooks.denycreatebranch +# This boolean sets whether remotely creating branches will be denied +# in the repository. By default this is allowed. +# + +# --- Command line +refname="$1" +oldrev="$2" +newrev="$3" + +# --- Safety check +if [ -z "$GIT_DIR" ]; then + echo "Don't run this script from the command line." >&2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 <ref> <oldrev> <newrev>)" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 <ref> <oldrev> <newrev>" >&2 + exit 1 +fi + +# --- Config +allowunannotated=$(git config --bool hooks.allowunannotated) +allowdeletebranch=$(git config --bool hooks.allowdeletebranch) +denycreatebranch=$(git config --bool hooks.denycreatebranch) +allowdeletetag=$(git config --bool hooks.allowdeletetag) +allowmodifytag=$(git config --bool hooks.allowmodifytag) + +# check for no description +projectdesc=$(sed -e '1q' "$GIT_DIR/description") +case "$projectdesc" in +"Unnamed repository"* | "") + echo "*** Project description file hasn't been set" >&2 + exit 1 + ;; +esac + +# --- Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero="0000000000000000000000000000000000000000" +if [ "$newrev" = "$zero" ]; then + newrev_type=delete +else + newrev_type=$(git cat-file -t $newrev) +fi + +case "$refname","$newrev_type" in + refs/tags/*,commit) + # un-annotated tag + short_refname=${refname##refs/tags/} + if [ "$allowunannotated" != "true" ]; then + echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2 + echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 + exit 1 + fi + ;; + refs/tags/*,delete) + # delete tag + if [ "$allowdeletetag" != "true" ]; then + echo "*** Deleting a tag is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/tags/*,tag) + # annotated tag + if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 + then + echo "*** Tag '$refname' already exists." >&2 + echo "*** Modifying a tag is not allowed in this repository." >&2 + exit 1 + fi + ;; + refs/heads/*,commit) + # branch + if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then + echo "*** Creating a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/heads/*,delete) + # delete branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/remotes/*,commit) + # tracking branch + ;; + refs/remotes/*,delete) + # delete tracking branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a tracking branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + *) + # Anything else (is there anything else?) + echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 + exit 1 + ;; +esac + +# --- Finished +exit 0 diff --git a/modules/git/tests/repos/repo1_bare/info/exclude b/modules/git/tests/repos/repo1_bare/info/exclude new file mode 100644 index 0000000000..a5196d1be8 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/modules/git/tests/repos/repo1_bare/objects/0b/9f291245f6c596fd30bee925fe94fe0cbadd60 b/modules/git/tests/repos/repo1_bare/objects/0b/9f291245f6c596fd30bee925fe94fe0cbadd60 Binary files differnew file mode 100644 index 0000000000..11de5add68 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/0b/9f291245f6c596fd30bee925fe94fe0cbadd60 diff --git a/modules/git/tests/repos/repo1_bare/objects/11/93ff46343f4f6a0522e2b28b871e905178c1f0 b/modules/git/tests/repos/repo1_bare/objects/11/93ff46343f4f6a0522e2b28b871e905178c1f0 Binary files differnew file mode 100644 index 0000000000..3541cd14f0 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/11/93ff46343f4f6a0522e2b28b871e905178c1f0 diff --git a/modules/git/tests/repos/repo1_bare/objects/15/3f451b9ee7fa1da317ab17a127e9fd9d384310 b/modules/git/tests/repos/repo1_bare/objects/15/3f451b9ee7fa1da317ab17a127e9fd9d384310 Binary files differnew file mode 100644 index 0000000000..8db3c790b5 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/15/3f451b9ee7fa1da317ab17a127e9fd9d384310 diff --git a/modules/git/tests/repos/repo1_bare/objects/18/4d49c75a0b202b1d2ea2fcb5861c329321fcd6 b/modules/git/tests/repos/repo1_bare/objects/18/4d49c75a0b202b1d2ea2fcb5861c329321fcd6 Binary files differnew file mode 100644 index 0000000000..45e014ea85 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/18/4d49c75a0b202b1d2ea2fcb5861c329321fcd6 diff --git a/modules/git/tests/repos/repo1_bare/objects/21/6bf54c2f2e2916b830ebe09e8c58a6ed52d86b b/modules/git/tests/repos/repo1_bare/objects/21/6bf54c2f2e2916b830ebe09e8c58a6ed52d86b Binary files differnew file mode 100644 index 0000000000..8c0b1b3285 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/21/6bf54c2f2e2916b830ebe09e8c58a6ed52d86b diff --git a/modules/git/tests/repos/repo1_bare/objects/28/39944139e0de9737a044f78b0e4b40d989a9e3 b/modules/git/tests/repos/repo1_bare/objects/28/39944139e0de9737a044f78b0e4b40d989a9e3 new file mode 100644 index 0000000000..e22e656fbe --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/28/39944139e0de9737a044f78b0e4b40d989a9e3 @@ -0,0 +1 @@ +x
0@QΙ"il q8BX'uSo6*摨",ɓTsI\I+r,|2[j얅V*u>KT?P4Rt@On[,\`oώ3]TzKe;
\ No newline at end of file diff --git a/modules/git/tests/repos/repo1_bare/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752e b/modules/git/tests/repos/repo1_bare/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752e Binary files differnew file mode 100644 index 0000000000..3e46ba4a32 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752e diff --git a/modules/git/tests/repos/repo1_bare/objects/30/4c56b3bef33d0afeb8515ee803c839daf30ab8 b/modules/git/tests/repos/repo1_bare/objects/30/4c56b3bef33d0afeb8515ee803c839daf30ab8 Binary files differnew file mode 100644 index 0000000000..3a5c6c1962 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/30/4c56b3bef33d0afeb8515ee803c839daf30ab8 diff --git a/modules/git/tests/repos/repo1_bare/objects/34/d1da713bf7de1c535e1d7d3ca985afd84bc7e5 b/modules/git/tests/repos/repo1_bare/objects/34/d1da713bf7de1c535e1d7d3ca985afd84bc7e5 Binary files differnew file mode 100644 index 0000000000..29f2d4fd00 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/34/d1da713bf7de1c535e1d7d3ca985afd84bc7e5 diff --git a/modules/git/tests/repos/repo1_bare/objects/37/991dec2c8e592043f47155ce4808d4580f9123 b/modules/git/tests/repos/repo1_bare/objects/37/991dec2c8e592043f47155ce4808d4580f9123 new file mode 100644 index 0000000000..3658e95141 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/37/991dec2c8e592043f47155ce4808d4580f9123 @@ -0,0 +1 @@ +xKN1Y#3 !p.LJ,k}5PlB5sKH|>dKKl k%S7ܛeӢcΉHSe~uLxoce:D7Әg_B=":S^ETup?6M^
\ No newline at end of file diff --git a/modules/git/tests/repos/repo1_bare/objects/38/441bf2c4d4c27efff94728c9eb33266f44a702 b/modules/git/tests/repos/repo1_bare/objects/38/441bf2c4d4c27efff94728c9eb33266f44a702 Binary files differnew file mode 100644 index 0000000000..c3d484aee0 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/38/441bf2c4d4c27efff94728c9eb33266f44a702 diff --git a/modules/git/tests/repos/repo1_bare/objects/3a/d28a9149a2864384548f3d17ed7f38014c9e8a b/modules/git/tests/repos/repo1_bare/objects/3a/d28a9149a2864384548f3d17ed7f38014c9e8a Binary files differnew file mode 100644 index 0000000000..ee2652b1c5 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/3a/d28a9149a2864384548f3d17ed7f38014c9e8a diff --git a/modules/git/tests/repos/repo1_bare/objects/50/13716a9da8e66ea21059a84f1b4311424d2b7f b/modules/git/tests/repos/repo1_bare/objects/50/13716a9da8e66ea21059a84f1b4311424d2b7f Binary files differnew file mode 100644 index 0000000000..b96905930a --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/50/13716a9da8e66ea21059a84f1b4311424d2b7f diff --git a/modules/git/tests/repos/repo1_bare/objects/59/dfb0bb505a601006e31fed53d2e24e44fca9ca b/modules/git/tests/repos/repo1_bare/objects/59/dfb0bb505a601006e31fed53d2e24e44fca9ca Binary files differnew file mode 100644 index 0000000000..16292715bd --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/59/dfb0bb505a601006e31fed53d2e24e44fca9ca diff --git a/modules/git/tests/repos/repo1_bare/objects/5c/80b0245c1c6f8343fa418ec374b13b5d4ee658 b/modules/git/tests/repos/repo1_bare/objects/5c/80b0245c1c6f8343fa418ec374b13b5d4ee658 new file mode 100644 index 0000000000..234d41bbc0 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/5c/80b0245c1c6f8343fa418ec374b13b5d4ee658 @@ -0,0 +1,3 @@ +x; +1@s:f7#xL!F㻈++hZbHJ<JB +VK6<5J)J1I)d#1 Tw+3+`{;=FD#ߡٿB<<3>=
\ No newline at end of file diff --git a/modules/git/tests/repos/repo1_bare/objects/62/d735f9efa9cf5b7df6bac9917b80e4779f4315 b/modules/git/tests/repos/repo1_bare/objects/62/d735f9efa9cf5b7df6bac9917b80e4779f4315 Binary files differnew file mode 100644 index 0000000000..15b958a2a2 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/62/d735f9efa9cf5b7df6bac9917b80e4779f4315 diff --git a/modules/git/tests/repos/repo1_bare/objects/64/3a35374408002fcf2f0e8d42d262a1e0e2f80e b/modules/git/tests/repos/repo1_bare/objects/64/3a35374408002fcf2f0e8d42d262a1e0e2f80e Binary files differnew file mode 100644 index 0000000000..eb0ad47575 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/64/3a35374408002fcf2f0e8d42d262a1e0e2f80e diff --git a/modules/git/tests/repos/repo1_bare/objects/6c/493ff740f9380390d5c9ddef4af18697ac9375 b/modules/git/tests/repos/repo1_bare/objects/6c/493ff740f9380390d5c9ddef4af18697ac9375 Binary files differnew file mode 100644 index 0000000000..7d217a7850 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/6c/493ff740f9380390d5c9ddef4af18697ac9375 diff --git a/modules/git/tests/repos/repo1_bare/objects/6f/bd69e9823458e6c4a2fc5c0f6bc022b2f2acd1 b/modules/git/tests/repos/repo1_bare/objects/6f/bd69e9823458e6c4a2fc5c0f6bc022b2f2acd1 new file mode 100644 index 0000000000..5b2cfb21cb --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/6f/bd69e9823458e6c4a2fc5c0f6bc022b2f2acd1 @@ -0,0 +1 @@ +x1n0E3-&H&F+աo#d/X:zSiZb1ؔSag@F35];̈'Ɇ%sD5R,w_mkCv"?}m#8"#|xDtfET zz/N
\ No newline at end of file diff --git a/modules/git/tests/repos/repo1_bare/objects/7e/3b688f3369ca28ebafbda9f8ef39713dd12fc8 b/modules/git/tests/repos/repo1_bare/objects/7e/3b688f3369ca28ebafbda9f8ef39713dd12fc8 Binary files differnew file mode 100644 index 0000000000..113089d22b --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/7e/3b688f3369ca28ebafbda9f8ef39713dd12fc8 diff --git a/modules/git/tests/repos/repo1_bare/objects/80/06ff9adbf0cb94da7dad9e537e53817f9fa5c0 b/modules/git/tests/repos/repo1_bare/objects/80/06ff9adbf0cb94da7dad9e537e53817f9fa5c0 Binary files differnew file mode 100644 index 0000000000..808fe6efb6 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/80/06ff9adbf0cb94da7dad9e537e53817f9fa5c0 diff --git a/modules/git/tests/repos/repo1_bare/objects/82/26f571dcc2d2f33a7179d929b10b9c39faa631 b/modules/git/tests/repos/repo1_bare/objects/82/26f571dcc2d2f33a7179d929b10b9c39faa631 Binary files differnew file mode 100644 index 0000000000..6a194fb3a6 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/82/26f571dcc2d2f33a7179d929b10b9c39faa631 diff --git a/modules/git/tests/repos/repo1_bare/objects/83/b9c4da46ed59098a009f8640c77eac97b71dfe b/modules/git/tests/repos/repo1_bare/objects/83/b9c4da46ed59098a009f8640c77eac97b71dfe Binary files differnew file mode 100644 index 0000000000..8602ab5402 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/83/b9c4da46ed59098a009f8640c77eac97b71dfe diff --git a/modules/git/tests/repos/repo1_bare/objects/8d/92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2 b/modules/git/tests/repos/repo1_bare/objects/8d/92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2 Binary files differnew file mode 100644 index 0000000000..431a481078 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/8d/92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2 diff --git a/modules/git/tests/repos/repo1_bare/objects/95/bb4d39648ee7e325106df01a621c530863a653 b/modules/git/tests/repos/repo1_bare/objects/95/bb4d39648ee7e325106df01a621c530863a653 Binary files differnew file mode 100644 index 0000000000..6bb6a25648 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/95/bb4d39648ee7e325106df01a621c530863a653 diff --git a/modules/git/tests/repos/repo1_bare/objects/98/1ff127cc331753bba28e1377c35934f1ca9b56 b/modules/git/tests/repos/repo1_bare/objects/98/1ff127cc331753bba28e1377c35934f1ca9b56 Binary files differnew file mode 100644 index 0000000000..ae6c93ac81 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/98/1ff127cc331753bba28e1377c35934f1ca9b56 diff --git a/modules/git/tests/repos/repo1_bare/objects/9c/9aef8dd84e02bc7ec12641deb4c930a7c30185 b/modules/git/tests/repos/repo1_bare/objects/9c/9aef8dd84e02bc7ec12641deb4c930a7c30185 new file mode 100644 index 0000000000..8a263d0d6f --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/9c/9aef8dd84e02bc7ec12641deb4c930a7c30185 @@ -0,0 +1,2 @@ +xM +1@a=E.ӂ.<H۔j9\o:Ϗ6Co@2DI+QA[V
H9P,R %IӥC\]PKE+~ 'LF}Zv
liddPֿ\Kdɓ=ܲ:c
\ No newline at end of file diff --git a/modules/git/tests/repos/repo1_bare/objects/b1/4df6442ea5a1b382985a6549b85d435376c351 b/modules/git/tests/repos/repo1_bare/objects/b1/4df6442ea5a1b382985a6549b85d435376c351 Binary files differnew file mode 100644 index 0000000000..02fe24f0ae --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/b1/4df6442ea5a1b382985a6549b85d435376c351 diff --git a/modules/git/tests/repos/repo1_bare/objects/b1/fc9917b618c924cf4aa421dae74e8bf9b556d3 b/modules/git/tests/repos/repo1_bare/objects/b1/fc9917b618c924cf4aa421dae74e8bf9b556d3 Binary files differnew file mode 100644 index 0000000000..aacc5efc9d --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/b1/fc9917b618c924cf4aa421dae74e8bf9b556d3 diff --git a/modules/git/tests/repos/repo1_bare/objects/b7/5f44edbd9252c32bf9faa0c1257ffb3b126c24 b/modules/git/tests/repos/repo1_bare/objects/b7/5f44edbd9252c32bf9faa0c1257ffb3b126c24 Binary files differnew file mode 100644 index 0000000000..6c2e007e9f --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/b7/5f44edbd9252c32bf9faa0c1257ffb3b126c24 diff --git a/modules/git/tests/repos/repo1_bare/objects/c8/c90111bdc18b3afd2b2906007059e95ac8fdc3 b/modules/git/tests/repos/repo1_bare/objects/c8/c90111bdc18b3afd2b2906007059e95ac8fdc3 Binary files differnew file mode 100644 index 0000000000..57c5d7ce84 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/c8/c90111bdc18b3afd2b2906007059e95ac8fdc3 diff --git a/modules/git/tests/repos/repo1_bare/objects/d0/845fe2f85710b50d673dafe98236bf9f2023da b/modules/git/tests/repos/repo1_bare/objects/d0/845fe2f85710b50d673dafe98236bf9f2023da Binary files differnew file mode 100644 index 0000000000..d29ca5af88 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/d0/845fe2f85710b50d673dafe98236bf9f2023da diff --git a/modules/git/tests/repos/repo1_bare/objects/e2/129701f1a4d54dc44f03c93bca0a2aec7c5449 b/modules/git/tests/repos/repo1_bare/objects/e2/129701f1a4d54dc44f03c93bca0a2aec7c5449 Binary files differnew file mode 100644 index 0000000000..08245d0012 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/e2/129701f1a4d54dc44f03c93bca0a2aec7c5449 diff --git a/modules/git/tests/repos/repo1_bare/objects/f1/a6cb52b2d16773290cefe49ad0684b50a4f930 b/modules/git/tests/repos/repo1_bare/objects/f1/a6cb52b2d16773290cefe49ad0684b50a4f930 Binary files differnew file mode 100644 index 0000000000..6a412c76b1 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/f1/a6cb52b2d16773290cefe49ad0684b50a4f930 diff --git a/modules/git/tests/repos/repo1_bare/refs/heads/branch1 b/modules/git/tests/repos/repo1_bare/refs/heads/branch1 new file mode 100644 index 0000000000..eb33bd0936 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/refs/heads/branch1 @@ -0,0 +1 @@ +2839944139e0de9737a044f78b0e4b40d989a9e3 diff --git a/modules/git/tests/repos/repo1_bare/refs/heads/branch2 b/modules/git/tests/repos/repo1_bare/refs/heads/branch2 new file mode 100644 index 0000000000..0475e61f8c --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/refs/heads/branch2 @@ -0,0 +1 @@ +5c80b0245c1c6f8343fa418ec374b13b5d4ee658 diff --git a/modules/git/tests/repos/repo1_bare/refs/heads/master b/modules/git/tests/repos/repo1_bare/refs/heads/master new file mode 100644 index 0000000000..4804d9d8fe --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/refs/heads/master @@ -0,0 +1 @@ +37991dec2c8e592043f47155ce4808d4580f9123 diff --git a/modules/git/tests/repos/repo1_bare/refs/tags/test b/modules/git/tests/repos/repo1_bare/refs/tags/test new file mode 100644 index 0000000000..ee311722e5 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/refs/tags/test @@ -0,0 +1 @@ +3ad28a9149a2864384548f3d17ed7f38014c9e8a diff --git a/modules/git/tree.go b/modules/git/tree.go new file mode 100644 index 0000000000..5ec22a3a6f --- /dev/null +++ b/modules/git/tree.go @@ -0,0 +1,98 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "strings" +) + +// Tree represents a flat directory listing. +type Tree struct { + ID SHA1 + repo *Repository + + // parent tree + ptree *Tree + + entries Entries + entriesParsed bool + + entriesRecursive Entries + entriesRecursiveParsed bool +} + +// NewTree create a new tree according the repository and commit id +func NewTree(repo *Repository, id SHA1) *Tree { + return &Tree{ + ID: id, + repo: repo, + } +} + +// SubTree get a sub tree by the sub dir path +func (t *Tree) SubTree(rpath string) (*Tree, error) { + if len(rpath) == 0 { + return t, nil + } + + paths := strings.Split(rpath, "/") + var ( + err error + g = t + p = t + te *TreeEntry + ) + for _, name := range paths { + te, err = p.GetTreeEntryByPath(name) + if err != nil { + return nil, err + } + + g, err = t.repo.getTree(te.ID) + if err != nil { + return nil, err + } + g.ptree = p + p = g + } + return g, nil +} + +// ListEntries returns all entries of current tree. +func (t *Tree) ListEntries() (Entries, error) { + if t.entriesParsed { + return t.entries, nil + } + + stdout, err := NewCommand("ls-tree", t.ID.String()).RunInDirBytes(t.repo.Path) + if err != nil { + return nil, err + } + + t.entries, err = parseTreeEntries(stdout, t) + if err == nil { + t.entriesParsed = true + } + + return t.entries, err +} + +// ListEntriesRecursive returns all entries of current tree recursively including all subtrees +func (t *Tree) ListEntriesRecursive() (Entries, error) { + if t.entriesRecursiveParsed { + return t.entriesRecursive, nil + } + stdout, err := NewCommand("ls-tree", "-t", "-r", t.ID.String()).RunInDirBytes(t.repo.Path) + if err != nil { + return nil, err + } + + t.entriesRecursive, err = parseTreeEntries(stdout, t) + if err == nil { + t.entriesRecursiveParsed = true + } + + return t.entriesRecursive, err +} diff --git a/modules/git/tree_blob.go b/modules/git/tree_blob.go new file mode 100644 index 0000000000..a37f6b2279 --- /dev/null +++ b/modules/git/tree_blob.go @@ -0,0 +1,59 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "path" + "strings" +) + +// GetTreeEntryByPath get the tree entries according the sub dir +func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) { + if len(relpath) == 0 { + return &TreeEntry{ + ID: t.ID, + Type: ObjectTree, + mode: EntryModeTree, + }, nil + } + + relpath = path.Clean(relpath) + parts := strings.Split(relpath, "/") + var err error + tree := t + for i, name := range parts { + if i == len(parts)-1 { + entries, err := tree.ListEntries() + if err != nil { + return nil, err + } + for _, v := range entries { + if v.name == name { + return v, nil + } + } + } else { + tree, err = tree.SubTree(name) + if err != nil { + return nil, err + } + } + } + return nil, ErrNotExist{"", relpath} +} + +// GetBlobByPath get the blob object according the path +func (t *Tree) GetBlobByPath(relpath string) (*Blob, error) { + entry, err := t.GetTreeEntryByPath(relpath) + if err != nil { + return nil, err + } + + if !entry.IsDir() { + return entry.Blob(), nil + } + + return nil, ErrNotExist{"", relpath} +} diff --git a/modules/git/tree_entry.go b/modules/git/tree_entry.go new file mode 100644 index 0000000000..5b74e9a695 --- /dev/null +++ b/modules/git/tree_entry.go @@ -0,0 +1,205 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "io" + "sort" + "strconv" + "strings" +) + +// EntryMode the type of the object in the git tree +type EntryMode int + +// There are only a few file modes in Git. They look like unix file modes, but they can only be +// one of these. +const ( + // EntryModeBlob + EntryModeBlob EntryMode = 0x0100644 + // EntryModeExec + EntryModeExec EntryMode = 0x0100755 + // EntryModeSymlink + EntryModeSymlink EntryMode = 0x0120000 + // EntryModeCommit + EntryModeCommit EntryMode = 0x0160000 + // EntryModeTree + EntryModeTree EntryMode = 0x0040000 +) + +// TreeEntry the leaf in the git tree +type TreeEntry struct { + ID SHA1 + Type ObjectType + + mode EntryMode + name string + + ptree *Tree + + committed bool + + size int64 + sized bool +} + +// Name returns the name of the entry +func (te *TreeEntry) Name() string { + return te.name +} + +// Mode returns the mode of the entry +func (te *TreeEntry) Mode() EntryMode { + return te.mode +} + +// Size returns the size of the entry +func (te *TreeEntry) Size() int64 { + if te.IsDir() { + return 0 + } else if te.sized { + return te.size + } + + stdout, err := NewCommand("cat-file", "-s", te.ID.String()).RunInDir(te.ptree.repo.Path) + if err != nil { + return 0 + } + + te.sized = true + te.size, _ = strconv.ParseInt(strings.TrimSpace(stdout), 10, 64) + return te.size +} + +// IsSubModule if the entry is a sub module +func (te *TreeEntry) IsSubModule() bool { + return te.mode == EntryModeCommit +} + +// IsDir if the entry is a sub dir +func (te *TreeEntry) IsDir() bool { + return te.mode == EntryModeTree +} + +// IsLink if the entry is a symlink +func (te *TreeEntry) IsLink() bool { + return te.mode == EntryModeSymlink +} + +// Blob retrun the blob object the entry +func (te *TreeEntry) Blob() *Blob { + return &Blob{ + repo: te.ptree.repo, + TreeEntry: te, + } +} + +// FollowLink returns the entry pointed to by a symlink +func (te *TreeEntry) FollowLink() (*TreeEntry, error) { + if !te.IsLink() { + return nil, ErrBadLink{te.Name(), "not a symlink"} + } + + // read the link + r, err := te.Blob().Data() + if err != nil { + return nil, err + } + buf := make([]byte, te.Size()) + _, err = io.ReadFull(r, buf) + if err != nil { + return nil, err + } + + lnk := string(buf) + t := te.ptree + + // traverse up directories + for ; t != nil && strings.HasPrefix(lnk, "../"); lnk = lnk[3:] { + t = t.ptree + } + + if t == nil { + return nil, ErrBadLink{te.Name(), "points outside of repo"} + } + + target, err := t.GetTreeEntryByPath(lnk) + if err != nil { + if IsErrNotExist(err) { + return nil, ErrBadLink{te.Name(), "broken link"} + } + return nil, err + } + return target, nil +} + +// GetSubJumpablePathName return the full path of subdirectory jumpable ( contains only one directory ) +func (te *TreeEntry) GetSubJumpablePathName() string { + if te.IsSubModule() || !te.IsDir() { + return "" + } + tree, err := te.ptree.SubTree(te.name) + if err != nil { + return te.name + } + entries, _ := tree.ListEntries() + if len(entries) == 1 && entries[0].IsDir() { + name := entries[0].GetSubJumpablePathName() + if name != "" { + return te.name + "/" + name + } + } + return te.name +} + +// Entries a list of entry +type Entries []*TreeEntry + +type customSortableEntries struct { + Comparer func(s1, s2 string) bool + Entries +} + +var sorter = []func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool{ + func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool { + return (t1.IsDir() || t1.IsSubModule()) && !t2.IsDir() && !t2.IsSubModule() + }, + func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool { + return cmp(t1.name, t2.name) + }, +} + +func (ctes customSortableEntries) Len() int { return len(ctes.Entries) } + +func (ctes customSortableEntries) Swap(i, j int) { + ctes.Entries[i], ctes.Entries[j] = ctes.Entries[j], ctes.Entries[i] +} + +func (ctes customSortableEntries) Less(i, j int) bool { + t1, t2 := ctes.Entries[i], ctes.Entries[j] + var k int + for k = 0; k < len(sorter)-1; k++ { + s := sorter[k] + switch { + case s(t1, t2, ctes.Comparer): + return true + case s(t2, t1, ctes.Comparer): + return false + } + } + return sorter[k](t1, t2, ctes.Comparer) +} + +// Sort sort the list of entry +func (tes Entries) Sort() { + sort.Sort(customSortableEntries{func(s1, s2 string) bool { + return s1 < s2 + }, tes}) +} + +// CustomSort customizable string comparing sort entry list +func (tes Entries) CustomSort(cmp func(s1, s2 string) bool) { + sort.Sort(customSortableEntries{cmp, tes}) +} diff --git a/modules/git/tree_entry_test.go b/modules/git/tree_entry_test.go new file mode 100644 index 0000000000..52920bc00e --- /dev/null +++ b/modules/git/tree_entry_test.go @@ -0,0 +1,98 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func getTestEntries() Entries { + return Entries{ + &TreeEntry{name: "v1.0", mode: EntryModeTree}, + &TreeEntry{name: "v2.0", mode: EntryModeTree}, + &TreeEntry{name: "v2.1", mode: EntryModeTree}, + &TreeEntry{name: "v2.12", mode: EntryModeTree}, + &TreeEntry{name: "v2.2", mode: EntryModeTree}, + &TreeEntry{name: "v12.0", mode: EntryModeTree}, + &TreeEntry{name: "abc", mode: EntryModeBlob}, + &TreeEntry{name: "bcd", mode: EntryModeBlob}, + } +} + +func TestEntriesSort(t *testing.T) { + entries := getTestEntries() + entries.Sort() + assert.Equal(t, "v1.0", entries[0].Name()) + assert.Equal(t, "v12.0", entries[1].Name()) + assert.Equal(t, "v2.0", entries[2].Name()) + assert.Equal(t, "v2.1", entries[3].Name()) + assert.Equal(t, "v2.12", entries[4].Name()) + assert.Equal(t, "v2.2", entries[5].Name()) + assert.Equal(t, "abc", entries[6].Name()) + assert.Equal(t, "bcd", entries[7].Name()) +} + +func TestEntriesCustomSort(t *testing.T) { + entries := getTestEntries() + entries.CustomSort(func(s1, s2 string) bool { + return s1 > s2 + }) + assert.Equal(t, "v2.2", entries[0].Name()) + assert.Equal(t, "v2.12", entries[1].Name()) + assert.Equal(t, "v2.1", entries[2].Name()) + assert.Equal(t, "v2.0", entries[3].Name()) + assert.Equal(t, "v12.0", entries[4].Name()) + assert.Equal(t, "v1.0", entries[5].Name()) + assert.Equal(t, "bcd", entries[6].Name()) + assert.Equal(t, "abc", entries[7].Name()) +} + +func TestFollowLink(t *testing.T) { + r, err := OpenRepository("tests/repos/repo1_bare") + assert.NoError(t, err) + + commit, err := r.GetCommit("37991dec2c8e592043f47155ce4808d4580f9123") + assert.NoError(t, err) + + // get the symlink + lnk, err := commit.Tree.GetTreeEntryByPath("foo/bar/link_to_hello") + assert.NoError(t, err) + assert.True(t, lnk.IsLink()) + + // should be able to dereference to target + target, err := lnk.FollowLink() + assert.NoError(t, err) + assert.Equal(t, target.Name(), "hello") + assert.False(t, target.IsLink()) + assert.Equal(t, target.ID.String(), "b14df6442ea5a1b382985a6549b85d435376c351") + + // should error when called on normal file + target, err = commit.Tree.GetTreeEntryByPath("file1.txt") + assert.NoError(t, err) + _, err = target.FollowLink() + assert.Equal(t, err.Error(), "file1.txt: not a symlink") + + // should error for broken links + target, err = commit.Tree.GetTreeEntryByPath("foo/broken_link") + assert.NoError(t, err) + assert.True(t, target.IsLink()) + _, err = target.FollowLink() + assert.Equal(t, err.Error(), "broken_link: broken link") + + // should error for external links + target, err = commit.Tree.GetTreeEntryByPath("foo/outside_repo") + assert.NoError(t, err) + assert.True(t, target.IsLink()) + _, err = target.FollowLink() + assert.Equal(t, err.Error(), "outside_repo: points outside of repo") + + // testing fix for short link bug + target, err = commit.Tree.GetTreeEntryByPath("foo/link_short") + assert.NoError(t, err) + _, err = target.FollowLink() + assert.Equal(t, err.Error(), "link_short: broken link") +} diff --git a/modules/git/utils.go b/modules/git/utils.go new file mode 100644 index 0000000000..8f010321cf --- /dev/null +++ b/modules/git/utils.go @@ -0,0 +1,96 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +// ObjectCache provides thread-safe cache opeations. +type ObjectCache struct { + lock sync.RWMutex + cache map[string]interface{} +} + +func newObjectCache() *ObjectCache { + return &ObjectCache{ + cache: make(map[string]interface{}, 10), + } +} + +// Set add obj to cache +func (oc *ObjectCache) Set(id string, obj interface{}) { + oc.lock.Lock() + defer oc.lock.Unlock() + + oc.cache[id] = obj +} + +// Get get cached obj by id +func (oc *ObjectCache) Get(id string) (interface{}, bool) { + oc.lock.RLock() + defer oc.lock.RUnlock() + + obj, has := oc.cache[id] + return obj, has +} + +// isDir returns true if given path is a directory, +// or returns false when it's a file or does not exist. +func isDir(dir string) bool { + f, e := os.Stat(dir) + if e != nil { + return false + } + return f.IsDir() +} + +// isFile returns true if given path is a file, +// or returns false when it's a directory or does not exist. +func isFile(filePath string) bool { + f, e := os.Stat(filePath) + if e != nil { + return false + } + return !f.IsDir() +} + +// isExist checks whether a file or directory exists. +// It returns false when the file or directory does not exist. +func isExist(path string) bool { + _, err := os.Stat(path) + return err == nil || os.IsExist(err) +} + +func concatenateError(err error, stderr string) error { + if len(stderr) == 0 { + return err + } + return fmt.Errorf("%v - %s", err, stderr) +} + +// If the object is stored in its own file (i.e not in a pack file), +// this function returns the full path to the object file. +// It does not test if the file exists. +func filepathFromSHA1(rootdir, sha1 string) string { + return filepath.Join(rootdir, "objects", sha1[:2], sha1[2:]) +} + +// RefEndName return the end name of a ref name +func RefEndName(refStr string) string { + if strings.HasPrefix(refStr, BranchPrefix) { + return refStr[len(BranchPrefix):] + } + + if strings.HasPrefix(refStr, TagPrefix) { + return refStr[len(TagPrefix):] + } + + return refStr +} diff --git a/modules/notification/base/notifier.go b/modules/notification/base/notifier.go index bac90f5bb1..e44f3cc632 100644 --- a/modules/notification/base/notifier.go +++ b/modules/notification/base/notifier.go @@ -5,8 +5,8 @@ package base import ( - "code.gitea.io/git" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" ) // Notifier defines an interface to notify receiver diff --git a/modules/notification/base/null.go b/modules/notification/base/null.go index 608bd0dcaa..12be1999f9 100644 --- a/modules/notification/base/null.go +++ b/modules/notification/base/null.go @@ -5,8 +5,8 @@ package base import ( - "code.gitea.io/git" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" ) // NullNotifier implements a blank notifier diff --git a/modules/notification/notification.go b/modules/notification/notification.go index e38c36f7dd..e0de88346d 100644 --- a/modules/notification/notification.go +++ b/modules/notification/notification.go @@ -5,8 +5,8 @@ package notification import ( - "code.gitea.io/git" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/notification/base" "code.gitea.io/gitea/modules/notification/indexer" "code.gitea.io/gitea/modules/notification/mail" diff --git a/modules/notification/ui/ui.go b/modules/notification/ui/ui.go index 135e92abfd..9870043580 100644 --- a/modules/notification/ui/ui.go +++ b/modules/notification/ui/ui.go @@ -5,8 +5,8 @@ package ui import ( - "code.gitea.io/git" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/notification/base" ) diff --git a/modules/setting/git.go b/modules/setting/git.go index 59951fcb94..5d9701a435 100644 --- a/modules/setting/git.go +++ b/modules/setting/git.go @@ -7,8 +7,9 @@ package setting import ( "time" - "code.gitea.io/git" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" + version "github.com/mcuadros/go-version" ) diff --git a/modules/setting/setting.go b/modules/setting/setting.go index ac44f54f0a..95fca20b11 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -20,8 +20,8 @@ import ( "strings" "time" - "code.gitea.io/git" "code.gitea.io/gitea/modules/generate" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" _ "code.gitea.io/gitea/modules/minwinsvc" // import minwinsvc for windows services "code.gitea.io/gitea/modules/user" diff --git a/modules/test/context_tests.go b/modules/test/context_tests.go index af986001ae..d5a800d360 100644 --- a/modules/test/context_tests.go +++ b/modules/test/context_tests.go @@ -6,14 +6,13 @@ package test import ( "net/http" + "net/http/httptest" "net/url" "testing" - "code.gitea.io/git" "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/context" - - "net/http/httptest" + "code.gitea.io/gitea/modules/git" "github.com/go-macaron/session" "github.com/stretchr/testify/assert" diff --git a/modules/uploader/delete.go b/modules/uploader/delete.go index fbe451c5d0..2353f18c46 100644 --- a/modules/uploader/delete.go +++ b/modules/uploader/delete.go @@ -7,8 +7,8 @@ package uploader import ( "fmt" - "code.gitea.io/git" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" ) // DeleteRepoFileOptions holds the repository delete file options diff --git a/modules/uploader/update.go b/modules/uploader/update.go index 08caf11ee1..bc543c7ffa 100644 --- a/modules/uploader/update.go +++ b/modules/uploader/update.go @@ -8,8 +8,8 @@ import ( "fmt" "strings" - "code.gitea.io/git" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/setting" ) diff --git a/modules/uploader/upload.go b/modules/uploader/upload.go index bee3f1b9b1..81d7c3ba20 100644 --- a/modules/uploader/upload.go +++ b/modules/uploader/upload.go @@ -10,11 +10,10 @@ import ( "path" "strings" + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/setting" - - "code.gitea.io/git" - "code.gitea.io/gitea/models" ) // UploadRepoFileOptions contains the uploaded repository file options |