diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index af3b948a88..ce0b26050e 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1331,7 +1331,9 @@ editor.upload_file = Upload File
editor.edit_file = Edit File
editor.preview_changes = Preview Changes
editor.cannot_edit_lfs_files = LFS files cannot be edited in the web interface.
+editor.cannot_edit_too_large_file = The file is too large to be edited.
editor.cannot_edit_non_text_files = Binary files cannot be edited in the web interface.
+editor.file_not_editable_hint = But you can still rename or move it.
editor.edit_this_file = Edit File
editor.this_file_locked = File is locked
editor.must_be_on_a_branch = You must be on a branch to make or propose changes to this file.
diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go
index cbcb3a3b21..62bf8b182f 100644
--- a/routers/web/repo/editor.go
+++ b/routers/web/repo/editor.go
@@ -145,10 +145,6 @@ func editFile(ctx *context.Context, isNewFile bool) {
}
blob := entry.Blob()
- if blob.Size() >= setting.UI.MaxDisplayFileSize {
- ctx.NotFound(err)
- return
- }
buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, blob)
if err != nil {
@@ -162,22 +158,37 @@ func editFile(ctx *context.Context, isNewFile bool) {
defer dataRc.Close()
- ctx.Data["FileSize"] = blob.Size()
-
- // Only some file types are editable online as text.
- if !fInfo.st.IsRepresentableAsText() || fInfo.isLFSFile {
- ctx.NotFound(nil)
- return
+ if fInfo.isLFSFile {
+ lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath)
+ if err != nil {
+ ctx.ServerError("GetTreePathLock", err)
+ return
+ }
+ if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID {
+ ctx.NotFound(nil)
+ return
+ }
}
- d, _ := io.ReadAll(dataRc)
+ ctx.Data["FileSize"] = fInfo.fileSize
- buf = append(buf, d...)
- if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil {
- log.Error("ToUTF8: %v", err)
- ctx.Data["FileContent"] = string(buf)
+ // Only some file types are editable online as text.
+ if fInfo.isLFSFile {
+ ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_lfs_files")
+ } else if !fInfo.st.IsRepresentableAsText() {
+ ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_non_text_files")
+ } else if fInfo.fileSize >= setting.UI.MaxDisplayFileSize {
+ ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_too_large_file")
} else {
- ctx.Data["FileContent"] = content
+ d, _ := io.ReadAll(dataRc)
+
+ buf = append(buf, d...)
+ if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil {
+ log.Error("ToUTF8: %v", err)
+ ctx.Data["FileContent"] = string(buf)
+ } else {
+ ctx.Data["FileContent"] = content
+ }
}
} else {
// Append filename from query, or empty string to allow username the new file.
@@ -280,6 +291,10 @@ func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile b
operation := "update"
if isNewFile {
operation = "create"
+ } else if !form.Content.Has() && ctx.Repo.TreePath != form.TreePath {
+ // The form content only has data if file is representable as text, is not too large and not in lfs. If it doesn't
+ // have data, the only possible operation is a rename
+ operation = "rename"
}
if _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
@@ -292,7 +307,7 @@ func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile b
Operation: operation,
FromTreePath: ctx.Repo.TreePath,
TreePath: form.TreePath,
- ContentReader: strings.NewReader(strings.ReplaceAll(form.Content, "\r", "")),
+ ContentReader: strings.NewReader(strings.ReplaceAll(form.Content.Value(), "\r", "")),
},
},
Signoff: form.Signoff,
diff --git a/routers/web/repo/patch.go b/routers/web/repo/patch.go
index ca346b7e6c..3ffd8f89c4 100644
--- a/routers/web/repo/patch.go
+++ b/routers/web/repo/patch.go
@@ -99,7 +99,7 @@ func NewDiffPatchPost(ctx *context.Context) {
OldBranch: ctx.Repo.BranchName,
NewBranch: branchName,
Message: message,
- Content: strings.ReplaceAll(form.Content, "\r", ""),
+ Content: strings.ReplaceAll(form.Content.Value(), "\r", ""),
Author: gitCommitter,
Committer: gitCommitter,
})
diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go
index 3df6051975..a142b7c111 100644
--- a/routers/web/repo/view_file.go
+++ b/routers/web/repo/view_file.go
@@ -140,13 +140,6 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) {
ctx.Data["LFSLockHint"] = ctx.Tr("repo.editor.this_file_locked")
}
- // Assume file is not editable first.
- if fInfo.isLFSFile {
- ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_lfs_files")
- } else if !isRepresentableAsText {
- ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_non_text_files")
- }
-
// read all needed attributes which will be used later
// there should be no performance different between reading 2 or 4 here
attrsMap, err := attribute.CheckAttributes(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, attribute.CheckAttributeOpts{
@@ -243,21 +236,6 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) {
ctx.Data["FileContent"] = fileContent
ctx.Data["LineEscapeStatus"] = statuses
}
- if !fInfo.isLFSFile {
- if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) {
- if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID {
- ctx.Data["CanEditFile"] = false
- ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.this_file_locked")
- } else {
- ctx.Data["CanEditFile"] = true
- ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.edit_this_file")
- }
- } else if !ctx.Repo.RefFullName.IsBranch() {
- ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch")
- } else if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) {
- ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit")
- }
- }
case fInfo.st.IsPDF():
ctx.Data["IsPDFFile"] = true
@@ -309,15 +287,21 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) {
if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) {
if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID {
+ ctx.Data["CanEditFile"] = false
+ ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.this_file_locked")
ctx.Data["CanDeleteFile"] = false
ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.this_file_locked")
} else {
+ ctx.Data["CanEditFile"] = true
+ ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.edit_this_file")
ctx.Data["CanDeleteFile"] = true
ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.delete_this_file")
}
} else if !ctx.Repo.RefFullName.IsBranch() {
+ ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch")
ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch")
} else if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) {
+ ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit")
ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access")
}
}
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index a2827e516a..d11ad0a54c 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -10,6 +10,7 @@ import (
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
+ "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/context"
@@ -689,7 +690,7 @@ func (f *NewWikiForm) Validate(req *http.Request, errs binding.Errors) binding.E
// EditRepoFileForm form for changing repository file
type EditRepoFileForm struct {
TreePath string `binding:"Required;MaxSize(500)"`
- Content string
+ Content optional.Option[string]
CommitSummary string `binding:"MaxSize(100)"`
CommitMessage string
CommitChoice string `binding:"Required;MaxSize(50)"`
diff --git a/services/repository/files/update.go b/services/repository/files/update.go
index 712914a27e..e1acf6a92f 100644
--- a/services/repository/files/update.go
+++ b/services/repository/files/update.go
@@ -246,7 +246,7 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
contentStore := lfs.NewContentStore()
for _, file := range opts.Files {
switch file.Operation {
- case "create", "update":
+ case "create", "update", "rename":
if err := CreateOrUpdateFile(ctx, t, file, contentStore, repo.ID, hasOldBranch); err != nil {
return nil, err
}
@@ -488,31 +488,32 @@ func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file
}
}
- treeObjectContentReader := file.ContentReader
- var lfsMetaObject *git_model.LFSMetaObject
- if setting.LFS.StartServer && hasOldBranch {
- // Check there is no way this can return multiple infos
- attributesMap, err := attribute.CheckAttributes(ctx, t.gitRepo, "" /* use temp repo's working dir */, attribute.CheckAttributeOpts{
- Attributes: []string{attribute.Filter},
- Filenames: []string{file.Options.treePath},
- })
+ var oldEntry *git.TreeEntry
+ // Assume that the file.ContentReader of a pure rename operation is invalid. Use the file content how it's present in
+ // git instead
+ if file.Operation == "rename" {
+ lastCommitID, err := t.GetLastCommit(ctx)
+ if err != nil {
+ return err
+ }
+ commit, err := t.GetCommit(lastCommitID)
if err != nil {
return err
}
- if attributesMap[file.Options.treePath] != nil && attributesMap[file.Options.treePath].Get(attribute.Filter).ToString().Value() == "lfs" {
- // OK so we are supposed to LFS this data!
- pointer, err := lfs.GeneratePointer(treeObjectContentReader)
- if err != nil {
- return err
- }
- lfsMetaObject = &git_model.LFSMetaObject{Pointer: pointer, RepositoryID: repoID}
- treeObjectContentReader = strings.NewReader(pointer.StringContent())
+ if oldEntry, err = commit.GetTreeEntryByPath(file.Options.fromTreePath); err != nil {
+ return err
}
}
- // Add the object to the database
- objectHash, err := t.HashObject(ctx, treeObjectContentReader)
+ var objectHash string
+ var lfsPointer *lfs.Pointer
+ switch file.Operation {
+ case "create", "update":
+ objectHash, lfsPointer, err = createOrUpdateFileHash(ctx, t, file, hasOldBranch)
+ case "rename":
+ objectHash, lfsPointer, err = renameFileHash(ctx, t, oldEntry, file)
+ }
if err != nil {
return err
}
@@ -528,9 +529,9 @@ func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file
}
}
- if lfsMetaObject != nil {
+ if lfsPointer != nil {
// We have an LFS object - create it
- lfsMetaObject, err = git_model.NewLFSMetaObject(ctx, lfsMetaObject.RepositoryID, lfsMetaObject.Pointer)
+ lfsMetaObject, err := git_model.NewLFSMetaObject(ctx, repoID, *lfsPointer)
if err != nil {
return err
}
@@ -539,11 +540,20 @@ func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file
return err
}
if !exist {
- _, err := file.ContentReader.Seek(0, io.SeekStart)
- if err != nil {
- return err
+ var lfsContentReader io.Reader
+ if file.Operation != "rename" {
+ if _, err := file.ContentReader.Seek(0, io.SeekStart); err != nil {
+ return err
+ }
+ lfsContentReader = file.ContentReader
+ } else {
+ if lfsContentReader, err = oldEntry.Blob().DataAsync(); err != nil {
+ return err
+ }
+ defer lfsContentReader.(io.ReadCloser).Close()
}
- if err := contentStore.Put(lfsMetaObject.Pointer, file.ContentReader); err != nil {
+
+ if err := contentStore.Put(lfsMetaObject.Pointer, lfsContentReader); err != nil {
if _, err2 := git_model.RemoveLFSMetaObjectByOid(ctx, repoID, lfsMetaObject.Oid); err2 != nil {
return fmt.Errorf("unable to remove failed inserted LFS object %s: %v (Prev Error: %w)", lfsMetaObject.Oid, err2, err)
}
@@ -555,6 +565,99 @@ func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file
return nil
}
+func createOrUpdateFileHash(ctx context.Context, t *TemporaryUploadRepository, file *ChangeRepoFile, hasOldBranch bool) (string, *lfs.Pointer, error) {
+ treeObjectContentReader := file.ContentReader
+ var lfsPointer *lfs.Pointer
+ if setting.LFS.StartServer && hasOldBranch {
+ // Check there is no way this can return multiple infos
+ attributesMap, err := attribute.CheckAttributes(ctx, t.gitRepo, "" /* use temp repo's working dir */, attribute.CheckAttributeOpts{
+ Attributes: []string{attribute.Filter},
+ Filenames: []string{file.Options.treePath},
+ })
+ if err != nil {
+ return "", nil, err
+ }
+
+ if attributesMap[file.Options.treePath] != nil && attributesMap[file.Options.treePath].Get(attribute.Filter).ToString().Value() == "lfs" {
+ // OK so we are supposed to LFS this data!
+ pointer, err := lfs.GeneratePointer(treeObjectContentReader)
+ if err != nil {
+ return "", nil, err
+ }
+ lfsPointer = &pointer
+ treeObjectContentReader = strings.NewReader(pointer.StringContent())
+ }
+ }
+
+ // Add the object to the database
+ objectHash, err := t.HashObject(ctx, treeObjectContentReader)
+ if err != nil {
+ return "", nil, err
+ }
+
+ return objectHash, lfsPointer, nil
+}
+
+func renameFileHash(ctx context.Context, t *TemporaryUploadRepository, oldEntry *git.TreeEntry, file *ChangeRepoFile) (string, *lfs.Pointer, error) {
+ if setting.LFS.StartServer {
+ attributesMap, err := attribute.CheckAttributes(ctx, t.gitRepo, "" /* use temp repo's working dir */, attribute.CheckAttributeOpts{
+ Attributes: []string{attribute.Filter},
+ Filenames: []string{file.Options.treePath, file.Options.fromTreePath},
+ })
+ if err != nil {
+ return "", nil, err
+ }
+
+ oldIsLfs := attributesMap[file.Options.fromTreePath] != nil && attributesMap[file.Options.fromTreePath].Get(attribute.Filter).ToString().Value() == "lfs"
+ newIsLfs := attributesMap[file.Options.treePath] != nil && attributesMap[file.Options.treePath].Get(attribute.Filter).ToString().Value() == "lfs"
+
+ // If the old and new paths are both in lfs or both not in lfs, the object hash of the old file can be used directly
+ // as the object doesn't change
+ if oldIsLfs == newIsLfs {
+ return oldEntry.ID.String(), nil, nil
+ }
+
+ oldEntryReader, err := oldEntry.Blob().DataAsync()
+ if err != nil {
+ return "", nil, err
+ }
+ defer oldEntryReader.Close()
+
+ var treeObjectContentReader io.Reader
+ var lfsPointer *lfs.Pointer
+ // If the old path is in lfs but the new isn't, read the content from lfs and add it as normal git object
+ // If the new path is in lfs but the old isn't, read the content from the git object and generate a lfs
+ // pointer of it
+ if oldIsLfs {
+ pointer, err := lfs.ReadPointer(oldEntryReader)
+ if err != nil {
+ return "", nil, err
+ }
+ treeObjectContentReader, err = lfs.ReadMetaObject(pointer)
+ if err != nil {
+ return "", nil, err
+ }
+ defer treeObjectContentReader.(io.ReadCloser).Close()
+ } else {
+ pointer, err := lfs.GeneratePointer(oldEntryReader)
+ if err != nil {
+ return "", nil, err
+ }
+ treeObjectContentReader = strings.NewReader(pointer.StringContent())
+ lfsPointer = &pointer
+ }
+
+ // Add the object to the database
+ objectID, err := t.HashObject(ctx, treeObjectContentReader)
+ if err != nil {
+ return "", nil, err
+ }
+ return objectID, lfsPointer, nil
+ }
+
+ return oldEntry.ID.String(), nil, nil
+}
+
// VerifyBranchProtection verify the branch protection for modifying the given treePath on the given branch
func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, branchName string, treePaths []string) error {
protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName)
diff --git a/services/repository/files/update_test.go b/services/repository/files/update_test.go
new file mode 100644
index 0000000000..e1a8b04c70
--- /dev/null
+++ b/services/repository/files/update_test.go
@@ -0,0 +1,54 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package files
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/lfs"
+ "code.gitea.io/gitea/services/contexttest"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestUpdateRename(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+ ctx, _ := contexttest.MockContext(t, "user2/repo1")
+ contexttest.LoadRepo(t, ctx, 1)
+ contexttest.LoadRepoCommit(t, ctx)
+ contexttest.LoadUser(t, ctx, 2)
+ contexttest.LoadGitRepo(t, ctx)
+ defer ctx.Repo.GitRepo.Close()
+
+ repo := ctx.Repo.Repository
+ branch := repo.DefaultBranch
+
+ temp, _ := NewTemporaryUploadRepository(repo)
+ _ = temp.Clone(ctx, branch, true)
+ _ = temp.SetDefaultIndex(ctx)
+
+ filesBeforeRename, _ := temp.LsFiles(ctx, "README.txt", "README.md")
+ assert.Equal(t, []string{"README.md", ""}, filesBeforeRename)
+
+ file := &ChangeRepoFile{
+ Operation: "rename",
+ FromTreePath: "README.md",
+ TreePath: "README.txt",
+ ContentReader: nil,
+ SHA: "",
+ Options: &RepoFileOptions{
+ fromTreePath: "README.md",
+ treePath: "README.txt",
+ executable: false,
+ },
+ }
+ contentStore := lfs.NewContentStore()
+
+ err := CreateOrUpdateFile(ctx, temp, file, contentStore, 1, true)
+ assert.NoError(t, err)
+
+ filesAfterRename, _ := temp.LsFiles(ctx, "README.txt", "README.md")
+ assert.Equal(t, []string{"README.txt", ""}, filesAfterRename)
+}
diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl
index fa96d2f0e2..22abf9a219 100644
--- a/templates/repo/diff/box.tmpl
+++ b/templates/repo/diff/box.tmpl
@@ -148,7 +148,7 @@
{{ctx.Locale.Tr "repo.diff.view_file"}}
{{else}}
{{ctx.Locale.Tr "repo.diff.view_file"}}
- {{if and $.Repository.CanEnableEditor $.CanEditFile (not $file.IsLFSFile) (not $file.IsBin)}}
+ {{if and $.Repository.CanEnableEditor $.CanEditFile}}
{{ctx.Locale.Tr "repo.editor.edit_this_file"}}
{{end}}
{{end}}
diff --git a/templates/repo/editor/commit_form.tmpl b/templates/repo/editor/commit_form.tmpl
index 8f46c47b96..7efed77349 100644
--- a/templates/repo/editor/commit_form.tmpl
+++ b/templates/repo/editor/commit_form.tmpl
@@ -77,7 +77,7 @@
{{end}}
-