diff --git a/models/lfs.go b/models/lfs.go index 9b2064277..5f5fe2ccf 100644 --- a/models/lfs.go +++ b/models/lfs.go @@ -8,6 +8,8 @@ import ( "io" "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/builder" ) // LFSMetaObject stores metadata for LFS tracked files. @@ -106,19 +108,91 @@ func (repo *Repository) GetLFSMetaObjectByOid(oid string) (*LFSMetaObject, error // RemoveLFSMetaObjectByOid removes a LFSMetaObject entry from database by its OID. // It may return ErrLFSObjectNotExist or a database error. -func (repo *Repository) RemoveLFSMetaObjectByOid(oid string) error { +func (repo *Repository) RemoveLFSMetaObjectByOid(oid string) (int64, error) { if len(oid) == 0 { - return ErrLFSObjectNotExist + return 0, ErrLFSObjectNotExist } sess := x.NewSession() defer sess.Close() if err := sess.Begin(); err != nil { - return err + return -1, err } m := &LFSMetaObject{Oid: oid, RepositoryID: repo.ID} if _, err := sess.Delete(m); err != nil { + return -1, err + } + + count, err := sess.Count(&LFSMetaObject{Oid: oid}) + if err != nil { + return count, err + } + + return count, sess.Commit() +} + +// GetLFSMetaObjects returns all LFSMetaObjects associated with a repository +func (repo *Repository) GetLFSMetaObjects(page, pageSize int) ([]*LFSMetaObject, error) { + sess := x.NewSession() + defer sess.Close() + + if page >= 0 && pageSize > 0 { + start := 0 + if page > 0 { + start = (page - 1) * pageSize + } + sess.Limit(pageSize, start) + } + lfsObjects := make([]*LFSMetaObject, 0, pageSize) + return lfsObjects, sess.Find(&lfsObjects, &LFSMetaObject{RepositoryID: repo.ID}) +} + +// CountLFSMetaObjects returns a count of all LFSMetaObjects associated with a repository +func (repo *Repository) CountLFSMetaObjects() (int64, error) { + return x.Count(&LFSMetaObject{RepositoryID: repo.ID}) +} + +// LFSObjectAccessible checks if a provided Oid is accessible to the user +func LFSObjectAccessible(user *User, oid string) (bool, error) { + if user.IsAdmin { + count, err := x.Count(&LFSMetaObject{Oid: oid}) + return (count > 0), err + } + cond := accessibleRepositoryCondition(user.ID) + count, err := x.Where(cond).Join("INNER", "repository", "`lfs_meta_object`.repository_id = `repository`.id").Count(&LFSMetaObject{Oid: oid}) + return (count > 0), err +} + +// LFSAutoAssociate auto associates accessible LFSMetaObjects +func LFSAutoAssociate(metas []*LFSMetaObject, user *User, repoID int64) error { + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + oids := make([]interface{}, len(metas)) + oidMap := make(map[string]*LFSMetaObject, len(metas)) + for i, meta := range metas { + oids[i] = meta.Oid + oidMap[meta.Oid] = meta + } + + cond := builder.NewCond() + if !user.IsAdmin { + cond = builder.In("`lfs_meta_object`.repository_id", + builder.Select("`repository`.id").From("repository").Where(accessibleRepositoryCondition(user.ID))) + } + newMetas := make([]*LFSMetaObject, 0, len(metas)) + if err := sess.Cols("oid").Where(cond).In("oid", oids...).GroupBy("oid").Find(&newMetas); err != nil { + return err + } + for i := range newMetas { + newMetas[i].Size = oidMap[newMetas[i].Oid].Size + newMetas[i].RepositoryID = repoID + } + if _, err := sess.InsertMulti(newMetas); err != nil { return err } diff --git a/models/repo_list.go b/models/repo_list.go index 692d4d002..c823647eb 100644 --- a/models/repo_list.go +++ b/models/repo_list.go @@ -176,28 +176,7 @@ func SearchRepository(opts *SearchRepoOptions) (RepositoryList, int64, error) { if opts.Private { if !opts.UserIsAdmin && opts.UserID != 0 && opts.UserID != opts.OwnerID { // OK we're in the context of a User - // We should be Either - cond = cond.And(builder.Or( - // 1. Be able to see all non-private repositories that either: - cond.And( - builder.Eq{"is_private": false}, - builder.Or( - // A. Aren't in organisations __OR__ - builder.NotIn("owner_id", builder.Select("id").From("`user`").Where(builder.Eq{"type": UserTypeOrganization})), - // B. Isn't a private organisation. (Limited is OK because we're logged in) - builder.NotIn("owner_id", builder.Select("id").From("`user`").Where(builder.Eq{"visibility": structs.VisibleTypePrivate}))), - ), - // 2. Be able to see all repositories that we have access to - builder.In("id", builder.Select("repo_id"). - From("`access`"). - Where(builder.And( - builder.Eq{"user_id": opts.UserID}, - builder.Gt{"mode": int(AccessModeNone)}))), - // 3. Be able to see all repositories that we are in a team - builder.In("id", builder.Select("`team_repo`.repo_id"). - From("team_repo"). - Where(builder.Eq{"`team_user`.uid": opts.UserID}). - Join("INNER", "team_user", "`team_user`.team_id = `team_repo`.team_id")))) + cond = cond.And(accessibleRepositoryCondition(opts.UserID)) } } else { // Not looking at private organisations @@ -316,6 +295,31 @@ func SearchRepository(opts *SearchRepoOptions) (RepositoryList, int64, error) { return repos, count, nil } +// accessibleRepositoryCondition takes a user a returns a condition for checking if a repository is accessible +func accessibleRepositoryCondition(userID int64) builder.Cond { + return builder.Or( + // 1. Be able to see all non-private repositories that either: + builder.And( + builder.Eq{"`repository`.is_private": false}, + builder.Or( + // A. Aren't in organisations __OR__ + builder.NotIn("`repository`.owner_id", builder.Select("id").From("`user`").Where(builder.Eq{"type": UserTypeOrganization})), + // B. Isn't a private organisation. (Limited is OK because we're logged in) + builder.NotIn("`repository`.owner_id", builder.Select("id").From("`user`").Where(builder.Eq{"visibility": structs.VisibleTypePrivate}))), + ), + // 2. Be able to see all repositories that we have access to + builder.In("`repository`.id", builder.Select("repo_id"). + From("`access`"). + Where(builder.And( + builder.Eq{"user_id": userID}, + builder.Gt{"mode": int(AccessModeNone)}))), + // 3. Be able to see all repositories that we are in a team + builder.In("`repository`.id", builder.Select("`team_repo`.repo_id"). + From("team_repo"). + Where(builder.Eq{"`team_user`.uid": userID}). + Join("INNER", "team_user", "`team_user`.team_id = `team_repo`.team_id"))) +} + // SearchRepositoryByName takes keyword and part of repository name to search, // it returns results in given range and number of total results. func SearchRepositoryByName(opts *SearchRepoOptions) (RepositoryList, int64, error) { diff --git a/modules/git/pipeline/catfile.go b/modules/git/pipeline/catfile.go new file mode 100644 index 000000000..7293cf9d7 --- /dev/null +++ b/modules/git/pipeline/catfile.go @@ -0,0 +1,94 @@ +// 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 pipeline + +import ( + "bufio" + "bytes" + "fmt" + "io" + "strconv" + "strings" + "sync" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" +) + +// CatFileBatchCheck runs cat-file with --batch-check +func CatFileBatchCheck(shasToCheckReader *io.PipeReader, catFileCheckWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) { + defer wg.Done() + defer shasToCheckReader.Close() + defer catFileCheckWriter.Close() + + stderr := new(bytes.Buffer) + var errbuf strings.Builder + cmd := git.NewCommand("cat-file", "--batch-check") + if err := cmd.RunInDirFullPipeline(tmpBasePath, catFileCheckWriter, stderr, shasToCheckReader); err != nil { + _ = catFileCheckWriter.CloseWithError(fmt.Errorf("git cat-file --batch-check [%s]: %v - %s", tmpBasePath, err, errbuf.String())) + } +} + +// CatFileBatchCheckAllObjects runs cat-file with --batch-check --batch-all +func CatFileBatchCheckAllObjects(catFileCheckWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string, errChan chan<- error) { + defer wg.Done() + defer catFileCheckWriter.Close() + + stderr := new(bytes.Buffer) + var errbuf strings.Builder + cmd := git.NewCommand("cat-file", "--batch-check", "--batch-all-objects") + if err := cmd.RunInDirPipeline(tmpBasePath, catFileCheckWriter, stderr); err != nil { + log.Error("git cat-file --batch-check --batch-all-object [%s]: %v - %s", tmpBasePath, err, errbuf.String()) + err = fmt.Errorf("git cat-file --batch-check --batch-all-object [%s]: %v - %s", tmpBasePath, err, errbuf.String()) + _ = catFileCheckWriter.CloseWithError(err) + errChan <- err + } +} + +// CatFileBatch runs cat-file --batch +func CatFileBatch(shasToBatchReader *io.PipeReader, catFileBatchWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) { + defer wg.Done() + defer shasToBatchReader.Close() + defer catFileBatchWriter.Close() + + stderr := new(bytes.Buffer) + var errbuf strings.Builder + if err := git.NewCommand("cat-file", "--batch").RunInDirFullPipeline(tmpBasePath, catFileBatchWriter, stderr, shasToBatchReader); err != nil { + _ = shasToBatchReader.CloseWithError(fmt.Errorf("git rev-list [%s]: %v - %s", tmpBasePath, err, errbuf.String())) + } +} + +// BlobsLessThan1024FromCatFileBatchCheck reads a pipeline from cat-file --batch-check and returns the blobs <1024 in size +func BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader *io.PipeReader, shasToBatchWriter *io.PipeWriter, wg *sync.WaitGroup) { + defer wg.Done() + defer catFileCheckReader.Close() + scanner := bufio.NewScanner(catFileCheckReader) + defer func() { + _ = shasToBatchWriter.CloseWithError(scanner.Err()) + }() + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 { + continue + } + fields := strings.Split(line, " ") + if len(fields) < 3 || fields[1] != "blob" { + continue + } + size, _ := strconv.Atoi(fields[2]) + if size > 1024 { + continue + } + toWrite := []byte(fields[0] + "\n") + for len(toWrite) > 0 { + n, err := shasToBatchWriter.Write(toWrite) + if err != nil { + _ = catFileCheckReader.CloseWithError(err) + break + } + toWrite = toWrite[n:] + } + } +} diff --git a/modules/git/pipeline/namerev.go b/modules/git/pipeline/namerev.go new file mode 100644 index 000000000..eebb53b0c --- /dev/null +++ b/modules/git/pipeline/namerev.go @@ -0,0 +1,28 @@ +// 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 pipeline + +import ( + "bytes" + "fmt" + "io" + "strings" + "sync" + + "code.gitea.io/gitea/modules/git" +) + +// NameRevStdin runs name-rev --stdin +func NameRevStdin(shasToNameReader *io.PipeReader, nameRevStdinWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) { + defer wg.Done() + defer shasToNameReader.Close() + defer nameRevStdinWriter.Close() + + stderr := new(bytes.Buffer) + var errbuf strings.Builder + if err := git.NewCommand("name-rev", "--stdin", "--name-only", "--always").RunInDirFullPipeline(tmpBasePath, nameRevStdinWriter, stderr, shasToNameReader); err != nil { + _ = shasToNameReader.CloseWithError(fmt.Errorf("git name-rev [%s]: %v - %s", tmpBasePath, err, errbuf.String())) + } +} diff --git a/modules/git/pipeline/revlist.go b/modules/git/pipeline/revlist.go new file mode 100644 index 000000000..4e13e1944 --- /dev/null +++ b/modules/git/pipeline/revlist.go @@ -0,0 +1,75 @@ +// 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 pipeline + +import ( + "bufio" + "bytes" + "fmt" + "io" + "strings" + "sync" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" +) + +// RevListAllObjects runs rev-list --objects --all and writes to a pipewriter +func RevListAllObjects(revListWriter *io.PipeWriter, wg *sync.WaitGroup, basePath string, errChan chan<- error) { + defer wg.Done() + defer revListWriter.Close() + + stderr := new(bytes.Buffer) + var errbuf strings.Builder + cmd := git.NewCommand("rev-list", "--objects", "--all") + if err := cmd.RunInDirPipeline(basePath, revListWriter, stderr); err != nil { + log.Error("git rev-list --objects --all [%s]: %v - %s", basePath, err, errbuf.String()) + err = fmt.Errorf("git rev-list --objects --all [%s]: %v - %s", basePath, err, errbuf.String()) + _ = revListWriter.CloseWithError(err) + errChan <- err + } +} + +// RevListObjects run rev-list --objects from headSHA to baseSHA +func RevListObjects(revListWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath, headSHA, baseSHA string, errChan chan<- error) { + defer wg.Done() + defer revListWriter.Close() + stderr := new(bytes.Buffer) + var errbuf strings.Builder + cmd := git.NewCommand("rev-list", "--objects", headSHA, "--not", baseSHA) + if err := cmd.RunInDirPipeline(tmpBasePath, revListWriter, stderr); err != nil { + log.Error("git rev-list [%s]: %v - %s", tmpBasePath, err, errbuf.String()) + errChan <- fmt.Errorf("git rev-list [%s]: %v - %s", tmpBasePath, err, errbuf.String()) + } +} + +// BlobsFromRevListObjects reads a RevListAllObjects and only selects blobs +func BlobsFromRevListObjects(revListReader *io.PipeReader, shasToCheckWriter *io.PipeWriter, wg *sync.WaitGroup) { + defer wg.Done() + defer revListReader.Close() + scanner := bufio.NewScanner(revListReader) + defer func() { + _ = shasToCheckWriter.CloseWithError(scanner.Err()) + }() + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 { + continue + } + fields := strings.Split(line, " ") + if len(fields) < 2 || len(fields[1]) == 0 { + continue + } + toWrite := []byte(fields[0] + "\n") + for len(toWrite) > 0 { + n, err := shasToCheckWriter.Write(toWrite) + if err != nil { + _ = revListReader.CloseWithError(err) + break + } + toWrite = toWrite[n:] + } + } +} diff --git a/modules/git/repo.go b/modules/git/repo.go index dd886f3a2..e1d75ca4a 100644 --- a/modules/git/repo.go +++ b/modules/git/repo.go @@ -117,6 +117,11 @@ func OpenRepository(repoPath string) (*Repository, error) { }, nil } +// GoGitRepo gets the go-git repo representation +func (repo *Repository) GoGitRepo() *gogit.Repository { + return repo.gogitRepo +} + // IsEmpty Check if repository is empty. func (repo *Repository) IsEmpty() (bool, error) { var errbuf strings.Builder diff --git a/modules/lfs/server.go b/modules/lfs/server.go index 6fa97a289..dc498a86c 100644 --- a/modules/lfs/server.go +++ b/modules/lfs/server.go @@ -332,7 +332,7 @@ func PutHandler(ctx *context.Context) { if err := contentStore.Put(meta, bodyReader); err != nil { ctx.Resp.WriteHeader(500) fmt.Fprintf(ctx.Resp, `{"message":"%s"}`, err) - if err = repository.RemoveLFSMetaObjectByOid(rv.Oid); err != nil { + if _, err = repository.RemoveLFSMetaObjectByOid(rv.Oid); err != nil { log.Error("RemoveLFSMetaObjectByOid: %v", err) } return diff --git a/modules/repofiles/update.go b/modules/repofiles/update.go index 8a1e51730..8e057700a 100644 --- a/modules/repofiles/update.go +++ b/modules/repofiles/update.go @@ -385,7 +385,7 @@ func CreateOrUpdateRepoFile(repo *models.Repository, doer *models.User, opts *Up contentStore := &lfs.ContentStore{BasePath: setting.LFS.ContentPath} if !contentStore.Exists(lfsMetaObject) { if err := contentStore.Put(lfsMetaObject, strings.NewReader(opts.Content)); err != nil { - if err2 := repo.RemoveLFSMetaObjectByOid(lfsMetaObject.Oid); err2 != nil { + if _, err2 := repo.RemoveLFSMetaObjectByOid(lfsMetaObject.Oid); err2 != nil { return nil, fmt.Errorf("Error whilst removing failed inserted LFS object %s: %v (Prev Error: %v)", lfsMetaObject.Oid, err2, err) } return nil, err diff --git a/modules/repofiles/upload.go b/modules/repofiles/upload.go index 202e66b89..a2e7cc927 100644 --- a/modules/repofiles/upload.go +++ b/modules/repofiles/upload.go @@ -36,7 +36,7 @@ func cleanUpAfterFailure(infos *[]uploadInfo, t *TemporaryUploadRepository, orig continue } if !info.lfsMetaObject.Existing { - if err := t.repo.RemoveLFSMetaObjectByOid(info.lfsMetaObject.Oid); err != nil { + if _, err := t.repo.RemoveLFSMetaObjectByOid(info.lfsMetaObject.Oid); err != nil { original = fmt.Errorf("%v, %v", original, err) } } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index f8e25a85f..4210ed121 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1378,6 +1378,21 @@ settings.unarchive.text = Un-Archiving the repo will restore its ability to rece settings.unarchive.success = The repo was successfully un-archived. settings.unarchive.error = An error occurred while trying to un-archive the repo. See the log for more details. settings.update_avatar_success = The repository avatar has been updated. +settings.lfs=LFS +settings.lfs_filelist=LFS files stored in this repository +settings.lfs_no_lfs_files=No LFS files stored in this repository +settings.lfs_findcommits=Find commits +settings.lfs_lfs_file_no_commits=No Commits found for this LFS file +settings.lfs_delete=Delete LFS file with OID %s +settings.lfs_delete_warning=Deleting an LFS file may cause 'object does not exist' errors on checkout. Are you sure? +settings.lfs_findpointerfiles=Find pointer files +settings.lfs_pointers.found=Found %d blob pointer(s) - %d associated, %d unassociated (%d missing from store) +settings.lfs_pointers.sha=Blob SHA +settings.lfs_pointers.oid=OID +settings.lfs_pointers.inRepo=In Repo +settings.lfs_pointers.exists=Exists in store +settings.lfs_pointers.accessible=Accessible to User +settings.lfs_pointers.associateAccessible=Associate accessible %d OIDs diff.browse_source = Browse Source diff.parent = parent diff --git a/public/css/index.css b/public/css/index.css index 68339cf0b..d0fe896a0 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -126,6 +126,7 @@ a{cursor:pointer} .ui .form .fake{display:none!important} .ui .form .sub.field{margin-left:25px} .ui .sha.label{font-family:'SF Mono',Consolas,Menlo,'Liberation Mono',Monaco,'Lucida Console',monospace;font-size:13px;padding:6px 10px 4px 10px;font-weight:400;margin:0 6px} +.ui .button.truncate{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;vertical-align:top;white-space:nowrap;margin-right:6px} .ui.status.buttons .octicon{margin-right:4px} .ui.inline.delete-button{padding:8px 15px;font-weight:400} .ui .background.red{background-color:#d95c5c!important} diff --git a/public/less/_base.less b/public/less/_base.less index a993bbed3..7fcfaf82e 100644 --- a/public/less/_base.less +++ b/public/less/_base.less @@ -539,6 +539,16 @@ code, margin: 0 6px; } + .button.truncate { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: top; + white-space: nowrap; + margin-right: 6px; + } + &.status.buttons { .octicon { margin-right: 4px; diff --git a/routers/repo/lfs.go b/routers/repo/lfs.go new file mode 100644 index 000000000..de5020c94 --- /dev/null +++ b/routers/repo/lfs.go @@ -0,0 +1,551 @@ +// 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 repo + +import ( + "bufio" + "bytes" + "fmt" + gotemplate "html/template" + "io" + "io/ioutil" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/pipeline" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + + "github.com/mcuadros/go-version" + "github.com/unknwon/com" + gogit "gopkg.in/src-d/go-git.v4" + "gopkg.in/src-d/go-git.v4/plumbing" + "gopkg.in/src-d/go-git.v4/plumbing/object" +) + +const ( + tplSettingsLFS base.TplName = "repo/settings/lfs" + tplSettingsLFSFile base.TplName = "repo/settings/lfs_file" + tplSettingsLFSFileFind base.TplName = "repo/settings/lfs_file_find" + tplSettingsLFSPointers base.TplName = "repo/settings/lfs_pointers" +) + +// LFSFiles shows a repository's LFS files +func LFSFiles(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSFiles", nil) + return + } + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + total, err := ctx.Repo.Repository.CountLFSMetaObjects() + if err != nil { + ctx.ServerError("LFSFiles", err) + return + } + + pager := context.NewPagination(int(total), setting.UI.ExplorePagingNum, page, 5) + ctx.Data["Title"] = ctx.Tr("repo.settings.lfs") + ctx.Data["PageIsSettingsLFS"] = true + lfsMetaObjects, err := ctx.Repo.Repository.GetLFSMetaObjects(pager.Paginater.Current(), setting.UI.ExplorePagingNum) + if err != nil { + ctx.ServerError("LFSFiles", err) + return + } + ctx.Data["LFSFiles"] = lfsMetaObjects + ctx.Data["Page"] = pager + ctx.HTML(200, tplSettingsLFS) +} + +// LFSFileGet serves a single LFS file +func LFSFileGet(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSFileGet", nil) + return + } + ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs" + oid := ctx.Params("oid") + ctx.Data["Title"] = oid + ctx.Data["PageIsSettingsLFS"] = true + meta, err := ctx.Repo.Repository.GetLFSMetaObjectByOid(oid) + if err != nil { + if err == models.ErrLFSObjectNotExist { + ctx.NotFound("LFSFileGet", nil) + return + } + ctx.ServerError("LFSFileGet", err) + return + } + ctx.Data["LFSFile"] = meta + dataRc, err := lfs.ReadMetaObject(meta) + if err != nil { + ctx.ServerError("LFSFileGet", err) + return + } + defer dataRc.Close() + buf := make([]byte, 1024) + n, err := dataRc.Read(buf) + if err != nil { + ctx.ServerError("Data", err) + return + } + buf = buf[:n] + + isTextFile := base.IsTextFile(buf) + ctx.Data["IsTextFile"] = isTextFile + + fileSize := meta.Size + ctx.Data["FileSize"] = meta.Size + ctx.Data["RawFileLink"] = fmt.Sprintf("%s%s.git/info/lfs/objects/%s/%s", setting.AppURL, ctx.Repo.Repository.FullName(), meta.Oid, "direct") + switch { + case isTextFile: + if fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + break + } + + d, _ := ioutil.ReadAll(dataRc) + buf = charset.ToUTF8WithFallback(append(buf, d...)) + + // Building code view blocks with line number on server side. + var fileContent string + if content, err := charset.ToUTF8WithErr(buf); err != nil { + log.Error("ToUTF8WithErr: %v", err) + fileContent = string(buf) + } else { + fileContent = content + } + + var output bytes.Buffer + lines := strings.Split(fileContent, "\n") + //Remove blank line at the end of file + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + for index, line := range lines { + line = gotemplate.HTMLEscapeString(line) + if index != len(lines)-1 { + line += "\n" + } + output.WriteString(fmt.Sprintf(`
  • %s
  • `, index+1, index+1, line)) + } + ctx.Data["FileContent"] = gotemplate.HTML(output.String()) + + output.Reset() + for i := 0; i < len(lines); i++ { + output.WriteString(fmt.Sprintf(`%d`, i+1, i+1)) + } + ctx.Data["LineNums"] = gotemplate.HTML(output.String()) + + case base.IsPDFFile(buf): + ctx.Data["IsPDFFile"] = true + case base.IsVideoFile(buf): + ctx.Data["IsVideoFile"] = true + case base.IsAudioFile(buf): + ctx.Data["IsAudioFile"] = true + case base.IsImageFile(buf): + ctx.Data["IsImageFile"] = true + } + ctx.HTML(200, tplSettingsLFSFile) +} + +// LFSDelete disassociates the provided oid from the repository and if the lfs file is no longer associated with any repositories - deletes it +func LFSDelete(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSDelete", nil) + return + } + oid := ctx.Params("oid") + count, err := ctx.Repo.Repository.RemoveLFSMetaObjectByOid(oid) + if err != nil { + ctx.ServerError("LFSDelete", err) + return + } + // FIXME: Warning: the LFS store is not locked - and can't be locked - there could be a race condition here + // Please note a similar condition happens in models/repo.go DeleteRepository + if count == 0 { + oidPath := filepath.Join(oid[0:2], oid[2:4], oid[4:]) + err = os.Remove(filepath.Join(setting.LFS.ContentPath, oidPath)) + if err != nil { + ctx.ServerError("LFSDelete", err) + return + } + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs") +} + +type lfsResult struct { + Name string + SHA string + Summary string + When time.Time + ParentHashes []plumbing.Hash + BranchName string + FullCommitName string +} + +type lfsResultSlice []*lfsResult + +func (a lfsResultSlice) Len() int { return len(a) } +func (a lfsResultSlice) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a lfsResultSlice) Less(i, j int) bool { return a[j].When.After(a[i].When) } + +// LFSFileFind guesses a sha for the provided oid (or uses the provided sha) and then finds the commits that contain this sha +func LFSFileFind(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSFind", nil) + return + } + oid := ctx.Query("oid") + size := ctx.QueryInt64("size") + if len(oid) == 0 || size == 0 { + ctx.NotFound("LFSFind", nil) + return + } + sha := ctx.Query("sha") + ctx.Data["Title"] = oid + ctx.Data["PageIsSettingsLFS"] = true + var hash plumbing.Hash + if len(sha) == 0 { + meta := models.LFSMetaObject{Oid: oid, Size: size} + pointer := meta.Pointer() + hash = plumbing.ComputeHash(plumbing.BlobObject, []byte(pointer)) + sha = hash.String() + } else { + hash = plumbing.NewHash(sha) + } + ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs" + ctx.Data["Oid"] = oid + ctx.Data["Size"] = size + ctx.Data["SHA"] = sha + + resultsMap := map[string]*lfsResult{} + results := make([]*lfsResult, 0) + + basePath := ctx.Repo.Repository.RepoPath() + gogitRepo := ctx.Repo.GitRepo.GoGitRepo() + + commitsIter, err := gogitRepo.Log(&gogit.LogOptions{ + Order: gogit.LogOrderCommitterTime, + All: true, + }) + if err != nil { + log.Error("Failed to get GoGit CommitsIter: %v", err) + ctx.ServerError("LFSFind: Iterate Commits", err) + return + } + + err = commitsIter.ForEach(func(gitCommit *object.Commit) error { + tree, err := gitCommit.Tree() + if err != nil { + return err + } + treeWalker := object.NewTreeWalker(tree, true, nil) + defer treeWalker.Close() + for { + name, entry, err := treeWalker.Next() + if err == io.EOF { + break + } + if entry.Hash == hash { + result := lfsResult{ + Name: name, + SHA: gitCommit.Hash.String(), + Summary: strings.Split(strings.TrimSpace(gitCommit.Message), "\n")[0], + When: gitCommit.Author.When, + ParentHashes: gitCommit.ParentHashes, + } + resultsMap[gitCommit.Hash.String()+":"+name] = &result + } + } + return nil + }) + if err != nil && err != io.EOF { + log.Error("Failure in CommitIter.ForEach: %v", err) + ctx.ServerError("LFSFind: IterateCommits ForEach", err) + return + } + + for _, result := range resultsMap { + hasParent := false + for _, parentHash := range result.ParentHashes { + if _, hasParent = resultsMap[parentHash.String()+":"+result.Name]; hasParent { + break + } + } + if !hasParent { + results = append(results, result) + } + } + + sort.Sort(lfsResultSlice(results)) + + // Should really use a go-git function here but name-rev is not completed and recapitulating it is not simple + shasToNameReader, shasToNameWriter := io.Pipe() + nameRevStdinReader, nameRevStdinWriter := io.Pipe() + errChan := make(chan error, 1) + wg := sync.WaitGroup{} + wg.Add(3) + + go func() { + defer wg.Done() + scanner := bufio.NewScanner(nameRevStdinReader) + i := 0 + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 { + continue + } + result := results[i] + result.FullCommitName = line + result.BranchName = strings.Split(line, "~")[0] + i++ + } + }() + go pipeline.NameRevStdin(shasToNameReader, nameRevStdinWriter, &wg, basePath) + go func() { + defer wg.Done() + defer shasToNameWriter.Close() + for _, result := range results { + i := 0 + if i < len(result.SHA) { + n, err := shasToNameWriter.Write([]byte(result.SHA)[i:]) + if err != nil { + errChan <- err + break + } + i += n + } + n := 0 + for n < 1 { + n, err = shasToNameWriter.Write([]byte{'\n'}) + if err != nil { + errChan <- err + break + } + + } + + } + }() + + wg.Wait() + + select { + case err, has := <-errChan: + if has { + ctx.ServerError("LFSPointerFiles", err) + } + default: + } + + ctx.Data["Results"] = results + ctx.HTML(200, tplSettingsLFSFileFind) +} + +// LFSPointerFiles will search the repository for pointer files and report which are missing LFS files in the content store +func LFSPointerFiles(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSFileGet", nil) + return + } + ctx.Data["PageIsSettingsLFS"] = true + binVersion, err := git.BinVersion() + if err != nil { + log.Fatal("Error retrieving git version: %v", err) + } + ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs" + + basePath := ctx.Repo.Repository.RepoPath() + + pointerChan := make(chan pointerResult) + + catFileCheckReader, catFileCheckWriter := io.Pipe() + shasToBatchReader, shasToBatchWriter := io.Pipe() + catFileBatchReader, catFileBatchWriter := io.Pipe() + errChan := make(chan error, 1) + wg := sync.WaitGroup{} + wg.Add(5) + + var numPointers, numAssociated, numNoExist, numAssociatable int + + go func() { + defer wg.Done() + pointers := make([]pointerResult, 0, 50) + for pointer := range pointerChan { + pointers = append(pointers, pointer) + if pointer.InRepo { + numAssociated++ + } + if !pointer.Exists { + numNoExist++ + } + if !pointer.InRepo && pointer.Accessible { + numAssociatable++ + } + } + numPointers = len(pointers) + ctx.Data["Pointers"] = pointers + ctx.Data["NumPointers"] = numPointers + ctx.Data["NumAssociated"] = numAssociated + ctx.Data["NumAssociatable"] = numAssociatable + ctx.Data["NumNoExist"] = numNoExist + ctx.Data["NumNotAssociated"] = numPointers - numAssociated + }() + go createPointerResultsFromCatFileBatch(catFileBatchReader, &wg, pointerChan, ctx.Repo.Repository, ctx.User) + go pipeline.CatFileBatch(shasToBatchReader, catFileBatchWriter, &wg, basePath) + go pipeline.BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader, shasToBatchWriter, &wg) + if !version.Compare(binVersion, "2.6.0", ">=") { + revListReader, revListWriter := io.Pipe() + shasToCheckReader, shasToCheckWriter := io.Pipe() + wg.Add(2) + go pipeline.CatFileBatchCheck(shasToCheckReader, catFileCheckWriter, &wg, basePath) + go pipeline.BlobsFromRevListObjects(revListReader, shasToCheckWriter, &wg) + go pipeline.RevListAllObjects(revListWriter, &wg, basePath, errChan) + } else { + go pipeline.CatFileBatchCheckAllObjects(catFileCheckWriter, &wg, basePath, errChan) + } + wg.Wait() + + select { + case err, has := <-errChan: + if has { + ctx.ServerError("LFSPointerFiles", err) + } + default: + } + ctx.HTML(200, tplSettingsLFSPointers) +} + +type pointerResult struct { + SHA string + Oid string + Size int64 + InRepo bool + Exists bool + Accessible bool +} + +func createPointerResultsFromCatFileBatch(catFileBatchReader *io.PipeReader, wg *sync.WaitGroup, pointerChan chan<- pointerResult, repo *models.Repository, user *models.User) { + defer wg.Done() + defer catFileBatchReader.Close() + contentStore := lfs.ContentStore{BasePath: setting.LFS.ContentPath} + + bufferedReader := bufio.NewReader(catFileBatchReader) + buf := make([]byte, 1025) + for { + // File descriptor line: sha + sha, err := bufferedReader.ReadString(' ') + if err != nil { + _ = catFileBatchReader.CloseWithError(err) + break + } + // Throw away the blob + if _, err := bufferedReader.ReadString(' '); err != nil { + _ = catFileBatchReader.CloseWithError(err) + break + } + sizeStr, err := bufferedReader.ReadString('\n') + if err != nil { + _ = catFileBatchReader.CloseWithError(err) + break + } + size, err := strconv.Atoi(sizeStr[:len(sizeStr)-1]) + if err != nil { + _ = catFileBatchReader.CloseWithError(err) + break + } + pointerBuf := buf[:size+1] + if _, err := io.ReadFull(bufferedReader, pointerBuf); err != nil { + _ = catFileBatchReader.CloseWithError(err) + break + } + pointerBuf = pointerBuf[:size] + // Now we need to check if the pointerBuf is an LFS pointer + pointer := lfs.IsPointerFile(&pointerBuf) + if pointer == nil { + continue + } + + result := pointerResult{ + SHA: strings.TrimSpace(sha), + Oid: pointer.Oid, + Size: pointer.Size, + } + + // Then we need to check that this pointer is in the db + if _, err := repo.GetLFSMetaObjectByOid(pointer.Oid); err != nil { + if err != models.ErrLFSObjectNotExist { + _ = catFileBatchReader.CloseWithError(err) + break + } + } else { + result.InRepo = true + } + + result.Exists = contentStore.Exists(pointer) + + if result.Exists { + if !result.InRepo { + // Can we fix? + // OK well that's "simple" + // - we need to check whether current user has access to a repo that has access to the file + result.Accessible, err = models.LFSObjectAccessible(user, result.Oid) + if err != nil { + _ = catFileBatchReader.CloseWithError(err) + break + } + } else { + result.Accessible = true + } + } + pointerChan <- result + } + close(pointerChan) +} + +// LFSAutoAssociate auto associates accessible lfs files +func LFSAutoAssociate(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSAutoAssociate", nil) + return + } + oids := ctx.QueryStrings("oid") + metas := make([]*models.LFSMetaObject, len(oids)) + for i, oid := range oids { + idx := strings.IndexRune(oid, ' ') + if idx < 0 || idx+1 > len(oid) { + ctx.ServerError("LFSAutoAssociate", fmt.Errorf("Illegal oid input: %s", oid)) + return + } + var err error + metas[i] = &models.LFSMetaObject{} + metas[i].Size, err = com.StrTo(oid[idx+1:]).Int64() + if err != nil { + ctx.ServerError("LFSAutoAssociate", fmt.Errorf("Illegal oid input: %s %v", oid, err)) + return + } + metas[i].Oid = oid[:idx] + //metas[i].RepositoryID = ctx.Repo.Repository.ID + } + if err := models.LFSAutoAssociate(metas, ctx.User, ctx.Repo.Repository.ID); err != nil { + ctx.ServerError("LFSAutoAssociate", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs") +} diff --git a/routers/routes/routes.go b/routers/routes/routes.go index 9572ea803..13a5bb270 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -677,8 +677,18 @@ func RegisterRoutes(m *macaron.Macaron) { m.Post("/delete", repo.DeleteDeployKey) }) + m.Group("/lfs", func() { + m.Get("", repo.LFSFiles) + m.Get("/show/:oid", repo.LFSFileGet) + m.Post("/delete/:oid", repo.LFSDelete) + m.Get("/pointers", repo.LFSPointerFiles) + m.Post("/pointers/associate", repo.LFSAutoAssociate) + m.Get("/find", repo.LFSFileFind) + }) + }, func(ctx *context.Context) { ctx.Data["PageIsSettings"] = true + ctx.Data["LFSStartServer"] = setting.LFS.StartServer }) }, reqSignIn, context.RepoAssignment(), context.UnitTypes(), reqRepoAdmin, context.RepoRef()) diff --git a/services/pull/lfs.go b/services/pull/lfs.go index 2706d3a20..a1981b825 100644 --- a/services/pull/lfs.go +++ b/services/pull/lfs.go @@ -7,15 +7,12 @@ package pull import ( "bufio" - "bytes" - "fmt" "io" "strconv" - "strings" "sync" "code.gitea.io/gitea/models" - "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/pipeline" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" ) @@ -41,22 +38,22 @@ func LFSPush(tmpBasePath, mergeHeadSHA, mergeBaseSHA string, pr *models.PullRequ // 6. Take the output of cat-file --batch and check if each file in turn // to see if they're pointers to files in the LFS store associated with // the head repo and add them to the base repo if so - go readCatFileBatch(catFileBatchReader, &wg, pr) + go createLFSMetaObjectsFromCatFileBatch(catFileBatchReader, &wg, pr) // 5. Take the shas of the blobs and batch read them - go doCatFileBatch(shasToBatchReader, catFileBatchWriter, &wg, tmpBasePath) + go pipeline.CatFileBatch(shasToBatchReader, catFileBatchWriter, &wg, tmpBasePath) // 4. From the provided objects restrict to blobs <=1k - go readCatFileBatchCheck(catFileCheckReader, shasToBatchWriter, &wg) + go pipeline.BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader, shasToBatchWriter, &wg) // 3. Run batch-check on the objects retrieved from rev-list - go doCatFileBatchCheck(shasToCheckReader, catFileCheckWriter, &wg, tmpBasePath) + go pipeline.CatFileBatchCheck(shasToCheckReader, catFileCheckWriter, &wg, tmpBasePath) // 2. Check each object retrieved rejecting those without names as they will be commits or trees - go readRevListObjects(revListReader, shasToCheckWriter, &wg) + go pipeline.BlobsFromRevListObjects(revListReader, shasToCheckWriter, &wg) // 1. Run rev-list objects from mergeHead to mergeBase - go doRevListObjects(revListWriter, &wg, tmpBasePath, mergeHeadSHA, mergeBaseSHA, errChan) + go pipeline.RevListObjects(revListWriter, &wg, tmpBasePath, mergeHeadSHA, mergeBaseSHA, errChan) wg.Wait() select { @@ -69,104 +66,7 @@ func LFSPush(tmpBasePath, mergeHeadSHA, mergeBaseSHA string, pr *models.PullRequ return nil } -func doRevListObjects(revListWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath, headSHA, baseSHA string, errChan chan<- error) { - defer wg.Done() - defer revListWriter.Close() - stderr := new(bytes.Buffer) - var errbuf strings.Builder - cmd := git.NewCommand("rev-list", "--objects", headSHA, "--not", baseSHA) - if err := cmd.RunInDirPipeline(tmpBasePath, revListWriter, stderr); err != nil { - log.Error("git rev-list [%s]: %v - %s", tmpBasePath, err, errbuf.String()) - errChan <- fmt.Errorf("git rev-list [%s]: %v - %s", tmpBasePath, err, errbuf.String()) - } -} - -func readRevListObjects(revListReader *io.PipeReader, shasToCheckWriter *io.PipeWriter, wg *sync.WaitGroup) { - defer wg.Done() - defer revListReader.Close() - defer shasToCheckWriter.Close() - scanner := bufio.NewScanner(revListReader) - for scanner.Scan() { - line := scanner.Text() - if len(line) == 0 { - continue - } - fields := strings.Split(line, " ") - if len(fields) < 2 || len(fields[1]) == 0 { - continue - } - toWrite := []byte(fields[0] + "\n") - for len(toWrite) > 0 { - n, err := shasToCheckWriter.Write(toWrite) - if err != nil { - _ = revListReader.CloseWithError(err) - break - } - toWrite = toWrite[n:] - } - } - _ = shasToCheckWriter.CloseWithError(scanner.Err()) -} - -func doCatFileBatchCheck(shasToCheckReader *io.PipeReader, catFileCheckWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) { - defer wg.Done() - defer shasToCheckReader.Close() - defer catFileCheckWriter.Close() - - stderr := new(bytes.Buffer) - var errbuf strings.Builder - cmd := git.NewCommand("cat-file", "--batch-check") - if err := cmd.RunInDirFullPipeline(tmpBasePath, catFileCheckWriter, stderr, shasToCheckReader); err != nil { - _ = catFileCheckWriter.CloseWithError(fmt.Errorf("git cat-file --batch-check [%s]: %v - %s", tmpBasePath, err, errbuf.String())) - } -} - -func readCatFileBatchCheck(catFileCheckReader *io.PipeReader, shasToBatchWriter *io.PipeWriter, wg *sync.WaitGroup) { - defer wg.Done() - defer catFileCheckReader.Close() - - scanner := bufio.NewScanner(catFileCheckReader) - defer func() { - _ = shasToBatchWriter.CloseWithError(scanner.Err()) - }() - for scanner.Scan() { - line := scanner.Text() - if len(line) == 0 { - continue - } - fields := strings.Split(line, " ") - if len(fields) < 3 || fields[1] != "blob" { - continue - } - size, _ := strconv.Atoi(fields[2]) - if size > 1024 { - continue - } - toWrite := []byte(fields[0] + "\n") - for len(toWrite) > 0 { - n, err := shasToBatchWriter.Write(toWrite) - if err != nil { - _ = catFileCheckReader.CloseWithError(err) - break - } - toWrite = toWrite[n:] - } - } -} - -func doCatFileBatch(shasToBatchReader *io.PipeReader, catFileBatchWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) { - defer wg.Done() - defer shasToBatchReader.Close() - defer catFileBatchWriter.Close() - - stderr := new(bytes.Buffer) - var errbuf strings.Builder - if err := git.NewCommand("cat-file", "--batch").RunInDirFullPipeline(tmpBasePath, catFileBatchWriter, stderr, shasToBatchReader); err != nil { - _ = shasToBatchReader.CloseWithError(fmt.Errorf("git rev-list [%s]: %v - %s", tmpBasePath, err, errbuf.String())) - } -} - -func readCatFileBatch(catFileBatchReader *io.PipeReader, wg *sync.WaitGroup, pr *models.PullRequest) { +func createLFSMetaObjectsFromCatFileBatch(catFileBatchReader *io.PipeReader, wg *sync.WaitGroup, pr *models.PullRequest) { defer wg.Done() defer catFileBatchReader.Close() diff --git a/templates/repo/settings/lfs.tmpl b/templates/repo/settings/lfs.tmpl new file mode 100644 index 000000000..e4480a8b9 --- /dev/null +++ b/templates/repo/settings/lfs.tmpl @@ -0,0 +1,62 @@ +{{template "base/head" .}} +
    + {{template "repo/header" .}} + {{template "repo/settings/navbar" .}} +
    + {{template "base/alert" .}} +

    + {{.i18n.Tr "repo.settings.lfs_filelist"}} + +

    + + + {{range .LFSFiles}} + + + + + + + {{else}} + + + + {{end}} + +
    + + + {{ShortSha .Oid}} + + + {{FileSize .Size}}{{TimeSince .CreatedUnix.AsTime $.Lang}} + {{$.i18n.Tr "repo.settings.lfs_findcommits"}} + +
    {{.i18n.Tr "repo.settings.lfs_no_lfs_files"}}
    + {{template "base/paginate" .}} + {{range .LFSFiles}} + + {{end}} +
    +
    +{{template "base/footer" .}} diff --git a/templates/repo/settings/lfs_file.tmpl b/templates/repo/settings/lfs_file.tmpl new file mode 100644 index 000000000..6283548ea --- /dev/null +++ b/templates/repo/settings/lfs_file.tmpl @@ -0,0 +1,57 @@ +{{template "base/head" .}} +
    + {{template "repo/header" .}} + {{template "repo/settings/navbar" .}} +
    + {{template "base/alert" .}} +
    +

    + {{.i18n.Tr "repo.settings.lfs"}} / {{.LFSFile.Oid}} + +

    +
    +
    + {{if .IsMarkup}} + {{if .FileContent}}{{.FileContent | Safe}}{{end}} + {{else if .IsRenderedHTML}} +
    {{if .FileContent}}{{.FileContent | Str2html}}{{end}}
    + {{else if not .IsTextFile}} +
    + {{if .IsImageFile}} + + {{else if .IsVideoFile}} + + {{else if .IsAudioFile}} + + {{else if .IsPDFFile}} + + {{else}} + {{.i18n.Tr "repo.file_view_raw"}} + {{end}} +
    + {{else if .FileSize}} + + + + {{if .IsFileTooLarge}} + + {{else}} + + + {{end}} + + +
    {{.i18n.Tr "repo.file_too_large"}}{{.LineNums}}
      {{.FileContent}}
    + {{end}} +
    +
    +
    +
    +
    +{{template "base/footer" .}} diff --git a/templates/repo/settings/lfs_file_find.tmpl b/templates/repo/settings/lfs_file_find.tmpl new file mode 100644 index 000000000..18db0215a --- /dev/null +++ b/templates/repo/settings/lfs_file_find.tmpl @@ -0,0 +1,52 @@ +{{template "base/head" .}} +
    + {{template "repo/header" .}} + {{template "repo/settings/navbar" .}} +
    + {{template "base/alert" .}} +
    +

    + {{.i18n.Tr "repo.settings.lfs"}} / {{.Oid}} +

    + + + {{range .Results}} + + + + + + + + {{else}} + + + + {{end}} + +
    + + {{.Name}} + + + + {{.Summary}} + + + + {{.BranchName}} + + {{if .ParentHashes}} + {{$.i18n.Tr "repo.diff.parent"}} + {{range .ParentHashes}} + {{ShortSha .String}} + {{end}} + {{end}} +
    + {{$.i18n.Tr "repo.diff.commit"}} + {{ShortSha .SHA}} +
    {{TimeSince .When $.Lang}}
    {{.i18n.Tr "repo.settings.lfs_lfs_file_no_commits"}}
    +
    +
    +
    +{{template "base/footer" .}} diff --git a/templates/repo/settings/lfs_pointers.tmpl b/templates/repo/settings/lfs_pointers.tmpl new file mode 100644 index 000000000..1bd48de15 --- /dev/null +++ b/templates/repo/settings/lfs_pointers.tmpl @@ -0,0 +1,71 @@ +{{template "base/head" .}} +
    + {{template "repo/header" .}} + {{template "repo/settings/navbar" .}} +
    + {{template "base/alert" .}} +

    + {{.i18n.Tr "repo.settings.lfs_pointers.found" .NumPointers .NumAssociated .NumNotAssociated .NumNoExist }} + {{if gt .NumAssociatable 0}} +
    +
    + {{.CsrfTokenHtml}} + {{range .Pointers}} + {{if and (not .InRepo) .Exists .Accessible}} + + {{end}} + {{end}} + +
    +
    + {{end}} +

    +
    + + + + + + + + + + + + + {{range .Pointers}} + + + + + + + + + {{end}} + +
    {{.i18n.Tr "repo.settings.lfs_pointers.sha"}}{{.i18n.Tr "repo.settings.lfs_pointers.oid"}}{{.i18n.Tr "repo.settings.lfs_pointers.inRepo"}}{{.i18n.Tr "repo.settings.lfs_pointers.exists"}}{{.i18n.Tr "repo.settings.lfs_pointers.accessible"}}
    + + + {{ShortSha .SHA}} + + + + + {{if and .Exists .InRepo}} + + {{ShortSha .Oid}} + + {{else}} + + {{ShortSha .Oid}} + + {{end}} + + + {{$.i18n.Tr "repo.settings.lfs_findcommits"}} +
    +
    +
    +
    +{{template "base/footer" .}} diff --git a/templates/repo/settings/navbar.tmpl b/templates/repo/settings/navbar.tmpl index 24082000e..abd6e285d 100644 --- a/templates/repo/settings/navbar.tmpl +++ b/templates/repo/settings/navbar.tmpl @@ -21,4 +21,9 @@ {{.i18n.Tr "repo.settings.deploy_keys"}} + {{if .LFSStartServer}} + + {{.i18n.Tr "repo.settings.lfs"}} + + {{end}}