mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-02 22:07:31 +08:00
Add git-like file commit API (#15978)
### What problem does this PR solve?
| # | Method | Endpoint | Description | Git Equivalent |
|---|--------|----------|-------------|----------------|
| 1 | `POST` | `/api/v1/{prefix}/{folder_id}/commits` | Create a
snapshot commit with file changes (add/modify/delete/rename) | `git add`
+ `git commit` |
| 2 | `GET` | `/api/v1/{prefix}/{folder_id}/commits` | List commit
history (paginated) | `git log` |
| 3 | `GET` | `/api/v1/{prefix}/{folder_id}/commits/{commit_id}` | Get
commit detail with file changes | `git show` |
| 4 | `GET` | `/api/v1/{prefix}/{folder_id}/commits/{commit_id}/files` |
List file changes in a commit | `git show --name-status` |
| 5 | `GET` |
`/api/v1/{prefix}/{folder_id}/commits/diff?from=...&to=...` | Compare
two commits and return differences | `git diff` |
| 6 | `GET` | `/api/v1/{prefix}/{folder_id}/changes` | Get uncommitted
changes (add/modify/delete) | `git status` |
| 7 | `GET` | `/api/v1/{prefix}/{folder_id}/commits/{commit_id}/tree` |
Get the folder tree snapshot at commit time | `git ls-tree` |
| 8 | `GET` |
`/api/v1/{prefix}/{folder_id}/commits/{commit_id}/files/{file_id}/content`
| Get a file's content as it existed in a specific commit | `git show
HEAD:file` |
| 9 | `GET` | `/api/v1/{prefix}/{file_id}/versions` | Get version
history for a specific file across all commits | `git log -- file` |
Where `{prefix}/{id}` can be:
- `folders/{folder_id}` — direct folder access
- `workspaces/{workspace_id}` — alias of `folders/{folder_id}`
- `datasets/{dataset_id}` — resolves to the dataset's folder
- `memories/{memory_id}` — resolves to the memory's folder
- `skills/{skill_id}` — resolves to the skill's folder
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
- [x] Documentation Update
This commit is contained in:
@@ -153,6 +153,8 @@ func InitDB() error {
|
||||
&entity.IngestionTaskLog{},
|
||||
&entity.IngestionTasklet{},
|
||||
&entity.IngestionTaskletLog{},
|
||||
&entity.FileCommit{},
|
||||
&entity.FileCommitItem{},
|
||||
}
|
||||
|
||||
for _, m := range dataModels {
|
||||
|
||||
@@ -243,6 +243,20 @@ func (dao *FileDAO) ListAllFilesByParentID(parentID string) ([]*entity.File, err
|
||||
return files, err
|
||||
}
|
||||
|
||||
// ListNonFolderByParentID lists non-folder files directly under a parent folder.
|
||||
func (dao *FileDAO) ListNonFolderByParentID(parentID string) ([]*entity.File, error) {
|
||||
var files []*entity.File
|
||||
err := DB.Where("parent_id = ? AND id != ? AND type != ?", parentID, parentID, "folder").Find(&files).Error
|
||||
return files, err
|
||||
}
|
||||
|
||||
// ListFolderByParentID lists sub-folders directly under a parent folder.
|
||||
func (dao *FileDAO) ListFolderByParentID(parentID string) ([]*entity.File, error) {
|
||||
var files []*entity.File
|
||||
err := DB.Where("parent_id = ? AND type = ?", parentID, "folder").Find(&files).Error
|
||||
return files, err
|
||||
}
|
||||
|
||||
// GetByParentIDAndName gets file by parent folder ID and name
|
||||
func (dao *FileDAO) GetByParentIDAndName(parentID, name string) (*entity.File, error) {
|
||||
var file entity.File
|
||||
|
||||
154
internal/dao/file_commit.go
Normal file
154
internal/dao/file_commit.go
Normal file
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"ragflow/internal/entity"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// FileCommitDAO file commit data access object
|
||||
type FileCommitDAO struct{}
|
||||
|
||||
// NewFileCommitDAO create file commit DAO
|
||||
func NewFileCommitDAO() *FileCommitDAO {
|
||||
return &FileCommitDAO{}
|
||||
}
|
||||
|
||||
// GetByID gets a file commit by ID
|
||||
func (dao *FileCommitDAO) GetByID(id string) (*entity.FileCommit, error) {
|
||||
var commit entity.FileCommit
|
||||
err := DB.Where("id = ?", id).First(&commit).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &commit, nil
|
||||
}
|
||||
|
||||
// Create creates a new file commit record
|
||||
func (dao *FileCommitDAO) Create(commit *entity.FileCommit) error {
|
||||
return DB.Create(commit).Error
|
||||
}
|
||||
|
||||
// UpdateTreeState updates the tree_state field for a commit
|
||||
func (dao *FileCommitDAO) UpdateTreeState(id string, treeState string) error {
|
||||
return DB.Model(&entity.FileCommit{}).Where("id = ?", id).Update("tree_state", treeState).Error
|
||||
}
|
||||
|
||||
// GetLatestByFolderID gets the latest (most recent) commit for a folder
|
||||
func (dao *FileCommitDAO) GetLatestByFolderID(folderID string) (*entity.FileCommit, error) {
|
||||
var commit entity.FileCommit
|
||||
err := DB.Where("folder_id = ?", folderID).
|
||||
Order("create_time DESC").
|
||||
First(&commit).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &commit, nil
|
||||
}
|
||||
|
||||
// allowedFileCommitSorts is the whitelist of safe column names for
|
||||
// ListByFolderID's orderBy parameter to prevent SQL injection.
|
||||
var allowedFileCommitSorts = map[string]string{
|
||||
"create_time": "create_time",
|
||||
"create_date": "create_date",
|
||||
"update_time": "update_time",
|
||||
"update_date": "update_date",
|
||||
}
|
||||
|
||||
// ListByFolderID lists commits for a folder with pagination
|
||||
func (dao *FileCommitDAO) ListByFolderID(folderID string, page, pageSize int, orderBy string, desc bool) ([]*entity.FileCommit, int64, error) {
|
||||
var commits []*entity.FileCommit
|
||||
var total int64
|
||||
|
||||
query := DB.Model(&entity.FileCommit{}).Where("folder_id = ?", folderID)
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Sanitize orderBy against whitelist; fall back to create_time.
|
||||
safeCol, ok := allowedFileCommitSorts[orderBy]
|
||||
if !ok {
|
||||
safeCol = "create_time"
|
||||
}
|
||||
|
||||
orderDirection := "DESC"
|
||||
if !desc {
|
||||
orderDirection = "ASC"
|
||||
}
|
||||
|
||||
orderClause := safeCol + " " + orderDirection
|
||||
|
||||
if page > 0 && pageSize > 0 {
|
||||
offset := (page - 1) * pageSize
|
||||
if err := query.Order(orderClause).Offset(offset).Limit(pageSize).Find(&commits).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
} else {
|
||||
if err := query.Order(orderClause).Find(&commits).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return commits, total, nil
|
||||
}
|
||||
|
||||
// FileCommitItemDAO file commit item data access object
|
||||
type FileCommitItemDAO struct{}
|
||||
|
||||
// NewFileCommitItemDAO create file commit item DAO
|
||||
func NewFileCommitItemDAO() *FileCommitItemDAO {
|
||||
return &FileCommitItemDAO{}
|
||||
}
|
||||
|
||||
// Create creates a new file commit item record
|
||||
func (dao *FileCommitItemDAO) Create(item *entity.FileCommitItem) error {
|
||||
return DB.Create(item).Error
|
||||
}
|
||||
|
||||
// ListByCommitID lists all items for a commit
|
||||
func (dao *FileCommitItemDAO) ListByCommitID(commitID string) ([]*entity.FileCommitItem, error) {
|
||||
var items []*entity.FileCommitItem
|
||||
err := DB.Where("commit_id = ?", commitID).Order("create_time ASC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// ListByFileID lists all commit items for a specific file (for version history)
|
||||
func (dao *FileCommitItemDAO) ListByFileID(fileID string) ([]*entity.FileCommitItem, error) {
|
||||
var items []*entity.FileCommitItem
|
||||
err := DB.Where("file_id = ?", fileID).Order("create_time DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// GetByCommitIDAndFileID gets a single commit item by commit and file ID
|
||||
func (dao *FileCommitItemDAO) GetByCommitIDAndFileID(commitID, fileID string) (*entity.FileCommitItem, error) {
|
||||
var item entity.FileCommitItem
|
||||
err := DB.Where("commit_id = ? AND file_id = ?", commitID, fileID).First(&item).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// generateCommitUUID generates a UUID for commit/commit_item IDs
|
||||
func generateCommitUUID() string {
|
||||
id := uuid.New().String()
|
||||
return strings.ReplaceAll(id, "-", "")
|
||||
}
|
||||
108
internal/entity/file_commit.go
Normal file
108
internal/entity/file_commit.go
Normal file
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
package entity
|
||||
|
||||
// FileCommit represents a snapshot commit for a workspace folder (like git commit).
|
||||
type FileCommit struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:32" json:"id"`
|
||||
FolderID string `gorm:"column:folder_id;size:32;not null;index" json:"folder_id"`
|
||||
ParentID *string `gorm:"column:parent_id;size:32;index" json:"parent_id,omitempty"`
|
||||
Message string `gorm:"column:message;size:512;not null;default:''" json:"message"`
|
||||
AuthorID string `gorm:"column:author_id;size:32;not null;index" json:"author_id"`
|
||||
FileCount int `gorm:"column:file_count;default:0" json:"file_count"`
|
||||
TreeState *string `gorm:"column:tree_state;type:longtext" json:"tree_state,omitempty"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
// TableName returns the table name for FileCommit model.
|
||||
func (FileCommit) TableName() string {
|
||||
return "file_commit"
|
||||
}
|
||||
|
||||
// FileCommitItem represents a single file change within a commit.
|
||||
type FileCommitItem struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:32" json:"id"`
|
||||
CommitID string `gorm:"column:commit_id;size:32;not null;uniqueIndex:idx_commit_file" json:"commit_id"`
|
||||
FileID string `gorm:"column:file_id;size:32;not null;uniqueIndex:idx_commit_file" json:"file_id"`
|
||||
Operation string `gorm:"column:operation;size:16;not null;index" json:"operation"`
|
||||
OldHash *string `gorm:"column:old_hash;size:64;index" json:"old_hash,omitempty"`
|
||||
NewHash *string `gorm:"column:new_hash;size:64;index" json:"new_hash,omitempty"`
|
||||
OldLocation *string `gorm:"column:old_location;size:255" json:"old_location,omitempty"`
|
||||
NewLocation *string `gorm:"column:new_location;size:255" json:"new_location,omitempty"`
|
||||
OldName *string `gorm:"column:old_name;size:255" json:"old_name,omitempty"`
|
||||
NewName *string `gorm:"column:new_name;size:255" json:"new_name,omitempty"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
// TableName returns the table name for FileCommitItem model.
|
||||
func (FileCommitItem) TableName() string {
|
||||
return "file_commit_item"
|
||||
}
|
||||
|
||||
// TreeNode represents a file node in the commit tree state snapshot.
|
||||
type TreeNode struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "file" or "folder"
|
||||
Hash string `json:"hash,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Status string `json:"status"` // "1" = active, "0" = deleted
|
||||
Children []*TreeNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// FileChange represents a file change in a commit request.
|
||||
type FileChange struct {
|
||||
FileID string `json:"file_id"`
|
||||
FileName string `json:"file_name"`
|
||||
Operation string `json:"operation"` // "add", "modify", "delete", "rename"
|
||||
Content string `json:"content,omitempty"`
|
||||
OldName string `json:"old_name,omitempty"`
|
||||
NewName string `json:"new_name,omitempty"`
|
||||
}
|
||||
|
||||
// CommitResponse is the API response for a commit.
|
||||
type CommitResponse struct {
|
||||
ID string `json:"id"`
|
||||
FolderID string `json:"folder_id"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
Message string `json:"message"`
|
||||
AuthorID string `json:"author_id"`
|
||||
FileCount int `json:"file_count"`
|
||||
TreeState *string `json:"tree_state,omitempty"`
|
||||
CreateTime *int64 `json:"create_time,omitempty"`
|
||||
}
|
||||
|
||||
// DiffEntry represents a single diff entry between two commits.
|
||||
type DiffEntry struct {
|
||||
FileID string `json:"file_id"`
|
||||
FileName string `json:"file_name"`
|
||||
Operation string `json:"operation"`
|
||||
OldHash *string `json:"old_hash,omitempty"`
|
||||
NewHash *string `json:"new_hash,omitempty"`
|
||||
OldLocation *string `json:"old_location,omitempty"`
|
||||
NewLocation *string `json:"new_location,omitempty"`
|
||||
}
|
||||
|
||||
// VersionEntry represents a single version in a file's version history.
|
||||
type VersionEntry struct {
|
||||
CommitID string `json:"commit_id"`
|
||||
Operation string `json:"operation"`
|
||||
Hash string `json:"hash"`
|
||||
CreateTime *int64 `json:"create_time,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
584
internal/handler/file_commit.go
Normal file
584
internal/handler/file_commit.go
Normal file
@@ -0,0 +1,584 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// fileCommitService is the consumer-side interface for FileCommitHandler's service dependency.
|
||||
type fileCommitService interface {
|
||||
CreateCommit(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error)
|
||||
ListCommits(folderID string, page, pageSize int, orderBy string, desc bool) ([]*entity.FileCommit, int64, error)
|
||||
GetCommit(commitID string) (*entity.FileCommit, error)
|
||||
ListCommitFiles(commitID string) ([]*entity.FileCommitItem, error)
|
||||
DiffCommits(fromID, toID string) ([]entity.DiffEntry, error)
|
||||
GetUncommittedChanges(folderID string) ([]entity.DiffEntry, error)
|
||||
GetCommitTree(commitID string) (map[string]interface{}, error)
|
||||
GetCommitFileContent(folderID, commitID, fileID string) ([]byte, error)
|
||||
GetFileVersionHistory(fileID string) ([]entity.VersionEntry, error)
|
||||
}
|
||||
|
||||
// FileCommitHandler file commit handler
|
||||
type FileCommitHandler struct {
|
||||
commitService fileCommitService
|
||||
kbDAO *dao.KnowledgebaseDAO
|
||||
fileDAO *dao.FileDAO
|
||||
}
|
||||
|
||||
// NewFileCommitHandler create file commit handler
|
||||
func NewFileCommitHandler(commitService fileCommitService) *FileCommitHandler {
|
||||
return &FileCommitHandler{
|
||||
commitService: commitService,
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
fileDAO: dao.NewFileDAO(),
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveFolderID resolves a resource ID (dataset/memory/skill) to its folder_id.
|
||||
// entityType is the plural resource name (e.g. "datasets", "memories", "skills").
|
||||
func (h *FileCommitHandler) ResolveFolderID(entityType, entityID string) (string, error) {
|
||||
switch entityType {
|
||||
case "datasets":
|
||||
return h.resolveDatasetFolderID(entityID)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported entity type: %s", entityType)
|
||||
}
|
||||
}
|
||||
|
||||
// CommitFolderResolver returns a Gin middleware that resolves an entity ID
|
||||
// (e.g. dataset_id, memory_id, skill_id) to a folder_id and injects it into
|
||||
// c.Params so that the standard commit handlers can read it via c.Param("folder_id").
|
||||
//
|
||||
// entityType must match the key used in ResolveFolderID (e.g. "datasets").
|
||||
// urlParam is the gin URL param name (e.g. "dataset_id").
|
||||
func CommitFolderResolver(h *FileCommitHandler, entityType, urlParam string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param(urlParam)
|
||||
if id == "" {
|
||||
jsonError(c, common.CodeParamError, fmt.Sprintf("%s is required", urlParam))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
folderID, err := h.ResolveFolderID(entityType, id)
|
||||
if err != nil {
|
||||
jsonError(c, common.CodeNotFound, fmt.Sprintf("%s folder not found", entityType))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Params = append(c.Params, gin.Param{Key: "folder_id", Value: folderID})
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *FileCommitHandler) resolveDatasetFolderID(datasetID string) (string, error) {
|
||||
kb, err := h.kbDAO.GetByID(datasetID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
files := h.fileDAO.Query(kb.Name, "")
|
||||
for _, f := range files {
|
||||
if f.SourceType == string(entity.FileSourceKnowledgebase) && f.Type == "folder" && f.TenantID == kb.TenantID {
|
||||
return f.ID, nil
|
||||
}
|
||||
}
|
||||
return "", common.ErrNotFound
|
||||
}
|
||||
|
||||
// CreateCommitRequest represents the request body for creating a commit
|
||||
type CreateCommitRequest struct {
|
||||
Message string `json:"message" binding:"required"`
|
||||
Files []entity.FileChange `json:"files" binding:"required"`
|
||||
}
|
||||
|
||||
// CreateCommit creates a new commit for a workspace folder
|
||||
// @Summary Create Commit
|
||||
// @Description Create a new commit with file changes for a workspace folder
|
||||
// @Tags file_commit
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param folder_id path string true "workspace folder ID"
|
||||
// @Param body body CreateCommitRequest true "commit request"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/workspaces/{folder_id}/commits [post]
|
||||
func (h *FileCommitHandler) CreateCommit(c *gin.Context) {
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
jsonError(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
folderID := c.Param("folder_id")
|
||||
if folderID == "" {
|
||||
jsonError(c, common.CodeParamError, "folder_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateCommitRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonError(c, common.CodeBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
commit, err := h.commitService.CreateCommit(folderID, user.ID, req.Message, req.Files)
|
||||
if err != nil {
|
||||
jsonInternalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
ct := int64(0)
|
||||
if commit.CreateTime != nil {
|
||||
ct = *commit.CreateTime
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeSuccess,
|
||||
"data": entity.CommitResponse{
|
||||
ID: commit.ID,
|
||||
FolderID: commit.FolderID,
|
||||
ParentID: commit.ParentID,
|
||||
Message: commit.Message,
|
||||
AuthorID: commit.AuthorID,
|
||||
FileCount: commit.FileCount,
|
||||
TreeState: commit.TreeState,
|
||||
CreateTime: &ct,
|
||||
},
|
||||
"message": common.CodeSuccess.Message(),
|
||||
})
|
||||
}
|
||||
|
||||
// ListCommits lists commits for a workspace folder
|
||||
// @Summary List Commits
|
||||
// @Description List all commits for a workspace folder with pagination
|
||||
// @Tags file_commit
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param folder_id path string true "workspace folder ID"
|
||||
// @Param page query int false "page number"
|
||||
// @Param page_size query int false "items per page"
|
||||
// @Param order_by query string false "order by field"
|
||||
// @Param desc query bool false "descending order"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/workspaces/{folder_id}/commits [get]
|
||||
func (h *FileCommitHandler) ListCommits(c *gin.Context) {
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
jsonError(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
folderID := c.Param("folder_id")
|
||||
if folderID == "" {
|
||||
jsonError(c, common.CodeParamError, "folder_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
page := 1
|
||||
if pageStr := c.Query("page"); pageStr != "" {
|
||||
if p, err := strconv.Atoi(pageStr); err == nil && p >= 1 {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
|
||||
pageSize := 15
|
||||
if psStr := c.Query("page_size"); psStr != "" {
|
||||
if ps, err := strconv.Atoi(psStr); err == nil {
|
||||
if ps < 1 {
|
||||
ps = 15
|
||||
}
|
||||
pageSize = ps
|
||||
}
|
||||
}
|
||||
|
||||
orderBy := c.DefaultQuery("order_by", "create_time")
|
||||
desc := true
|
||||
if descStr := c.Query("desc"); descStr != "" {
|
||||
desc = descStr != "false"
|
||||
}
|
||||
|
||||
commits, total, err := h.commitService.ListCommits(folderID, page, pageSize, orderBy, desc)
|
||||
if err != nil {
|
||||
jsonInternalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var commitList []entity.CommitResponse
|
||||
for _, commit := range commits {
|
||||
var ct int64
|
||||
if commit.CreateTime != nil {
|
||||
ct = *commit.CreateTime
|
||||
}
|
||||
commitList = append(commitList, entity.CommitResponse{
|
||||
ID: commit.ID,
|
||||
FolderID: commit.FolderID,
|
||||
ParentID: commit.ParentID,
|
||||
Message: commit.Message,
|
||||
AuthorID: commit.AuthorID,
|
||||
FileCount: commit.FileCount,
|
||||
CreateTime: &ct,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeSuccess,
|
||||
"data": gin.H{
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"commits": commitList,
|
||||
},
|
||||
"message": common.CodeSuccess.Message(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetCommit gets details of a single commit
|
||||
// @Summary Get Commit
|
||||
// @Description Get details of a single commit including file changes
|
||||
// @Tags file_commit
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param folder_id path string true "workspace folder ID"
|
||||
// @Param commit_id path string true "commit ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/workspaces/{folder_id}/commits/{commit_id} [get]
|
||||
func (h *FileCommitHandler) GetCommit(c *gin.Context) {
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
jsonError(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
folderID := c.Param("folder_id")
|
||||
commitID := c.Param("commit_id")
|
||||
if commitID == "" {
|
||||
jsonError(c, common.CodeParamError, "commit_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
commit, err := h.commitService.GetCommit(commitID)
|
||||
if err != nil {
|
||||
jsonError(c, common.CodeNotFound, "Commit not found")
|
||||
return
|
||||
}
|
||||
|
||||
if commit.FolderID != folderID {
|
||||
jsonError(c, common.CodeNotFound, "Commit not found in workspace")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.commitService.ListCommitFiles(commitID)
|
||||
if err != nil {
|
||||
items = []*entity.FileCommitItem{}
|
||||
}
|
||||
|
||||
var ct int64
|
||||
if commit.CreateTime != nil {
|
||||
ct = *commit.CreateTime
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeSuccess,
|
||||
"data": gin.H{
|
||||
"id": commit.ID,
|
||||
"folder_id": commit.FolderID,
|
||||
"parent_id": commit.ParentID,
|
||||
"message": commit.Message,
|
||||
"author_id": commit.AuthorID,
|
||||
"file_count": commit.FileCount,
|
||||
"create_time": ct,
|
||||
"files": items,
|
||||
},
|
||||
"message": common.CodeSuccess.Message(),
|
||||
})
|
||||
}
|
||||
|
||||
// ListCommitFiles lists all file changes in a commit
|
||||
// @Summary List Commit Files
|
||||
// @Description List all file changes in a given commit
|
||||
// @Tags file_commit
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param folder_id path string true "workspace folder ID"
|
||||
// @Param commit_id path string true "commit ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/workspaces/{folder_id}/commits/{commit_id}/files [get]
|
||||
func (h *FileCommitHandler) ListCommitFiles(c *gin.Context) {
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
jsonError(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
folderID := c.Param("folder_id")
|
||||
commitID := c.Param("commit_id")
|
||||
if commitID == "" {
|
||||
jsonError(c, common.CodeParamError, "commit_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
commit, err := h.commitService.GetCommit(commitID)
|
||||
if err != nil {
|
||||
jsonError(c, common.CodeNotFound, "Commit not found")
|
||||
return
|
||||
}
|
||||
if commit.FolderID != folderID {
|
||||
jsonError(c, common.CodeNotFound, "Commit not found in workspace")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.commitService.ListCommitFiles(commitID)
|
||||
if err != nil {
|
||||
jsonInternalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeSuccess,
|
||||
"data": items,
|
||||
"message": common.CodeSuccess.Message(),
|
||||
})
|
||||
}
|
||||
|
||||
// DiffCommits compares two commits
|
||||
// @Summary Diff Commits
|
||||
// @Description Compare two commits and return the diff
|
||||
// @Tags file_commit
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param folder_id path string true "workspace folder ID"
|
||||
// @Param from query string true "from commit ID"
|
||||
// @Param to query string true "to commit ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/workspaces/{folder_id}/commits/diff [get]
|
||||
func (h *FileCommitHandler) DiffCommits(c *gin.Context) {
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
jsonError(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
folderID := c.Param("folder_id")
|
||||
fromID := c.Query("from")
|
||||
toID := c.Query("to")
|
||||
if fromID == "" || toID == "" {
|
||||
jsonError(c, common.CodeParamError, "'from' and 'to' query parameters are required")
|
||||
return
|
||||
}
|
||||
|
||||
fromCommit, err := h.commitService.GetCommit(fromID)
|
||||
if err != nil {
|
||||
jsonError(c, common.CodeNotFound, "Commit not found")
|
||||
return
|
||||
}
|
||||
toCommit, err := h.commitService.GetCommit(toID)
|
||||
if err != nil {
|
||||
jsonError(c, common.CodeNotFound, "Commit not found")
|
||||
return
|
||||
}
|
||||
if fromCommit.FolderID != folderID || toCommit.FolderID != folderID {
|
||||
jsonError(c, common.CodeNotFound, "Commit not found in workspace")
|
||||
return
|
||||
}
|
||||
|
||||
diff, err := h.commitService.DiffCommits(fromID, toID)
|
||||
if err != nil {
|
||||
jsonInternalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeSuccess,
|
||||
"data": diff,
|
||||
"message": common.CodeSuccess.Message(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetUncommittedChanges gets uncommitted changes
|
||||
// @Summary Get Uncommitted Changes
|
||||
// @Description Get uncommitted changes for a workspace folder (like git status)
|
||||
// @Tags file_commit
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param folder_id path string true "workspace folder ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/workspaces/{folder_id}/changes [get]
|
||||
func (h *FileCommitHandler) GetUncommittedChanges(c *gin.Context) {
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
jsonError(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
folderID := c.Param("folder_id")
|
||||
if folderID == "" {
|
||||
jsonError(c, common.CodeParamError, "folder_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
changes, err := h.commitService.GetUncommittedChanges(folderID)
|
||||
if err != nil {
|
||||
jsonInternalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeSuccess,
|
||||
"data": changes,
|
||||
"message": common.CodeSuccess.Message(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetCommitTree gets the folder tree snapshot for a commit
|
||||
// @Summary Get Commit Tree
|
||||
// @Description Get the folder tree snapshot for a given commit
|
||||
// @Tags file_commit
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param folder_id path string true "workspace folder ID"
|
||||
// @Param commit_id path string true "commit ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/workspaces/{folder_id}/commits/{commit_id}/tree [get]
|
||||
func (h *FileCommitHandler) GetCommitTree(c *gin.Context) {
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
jsonError(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
folderID := c.Param("folder_id")
|
||||
commitID := c.Param("commit_id")
|
||||
if commitID == "" {
|
||||
jsonError(c, common.CodeParamError, "commit_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
commit, err := h.commitService.GetCommit(commitID)
|
||||
if err != nil {
|
||||
jsonError(c, common.CodeNotFound, "Commit not found")
|
||||
return
|
||||
}
|
||||
if commit.FolderID != folderID {
|
||||
jsonError(c, common.CodeNotFound, "Commit not found in workspace")
|
||||
return
|
||||
}
|
||||
|
||||
tree, err := h.commitService.GetCommitTree(commitID)
|
||||
if err != nil {
|
||||
jsonInternalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeSuccess,
|
||||
"data": tree,
|
||||
"message": common.CodeSuccess.Message(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetCommitFileContent gets file content as it existed in a given commit
|
||||
// @Summary Get Commit File Content
|
||||
// @Description Get file content as it existed in a specific commit
|
||||
// @Tags file_commit
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param folder_id path string true "workspace folder ID"
|
||||
// @Param commit_id path string true "commit ID"
|
||||
// @Param file_id path string true "file ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/workspaces/{folder_id}/commits/{commit_id}/files/{file_id}/content [get]
|
||||
func (h *FileCommitHandler) GetCommitFileContent(c *gin.Context) {
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
jsonError(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
folderID := c.Param("folder_id")
|
||||
commitID := c.Param("commit_id")
|
||||
fileID := c.Param("file_id")
|
||||
|
||||
if folderID == "" || commitID == "" || fileID == "" {
|
||||
jsonError(c, common.CodeParamError, "folder_id, commit_id, and file_id are required")
|
||||
return
|
||||
}
|
||||
|
||||
commit, err := h.commitService.GetCommit(commitID)
|
||||
if err != nil {
|
||||
jsonError(c, common.CodeNotFound, "Commit not found")
|
||||
return
|
||||
}
|
||||
if commit.FolderID != folderID {
|
||||
jsonError(c, common.CodeNotFound, "Commit not found in workspace")
|
||||
return
|
||||
}
|
||||
|
||||
content, err := h.commitService.GetCommitFileContent(folderID, commitID, fileID)
|
||||
if err != nil {
|
||||
jsonError(c, common.CodeNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeSuccess,
|
||||
"data": gin.H{
|
||||
"content": string(content),
|
||||
},
|
||||
"message": common.CodeSuccess.Message(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetFileVersionHistory gets version history for a specific file
|
||||
// @Summary Get File Version History
|
||||
// @Description Get version history for a specific file across all commits
|
||||
// @Tags file_commit
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param file_id path string true "file ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/files/{file_id}/versions [get]
|
||||
func (h *FileCommitHandler) GetFileVersionHistory(c *gin.Context) {
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
jsonError(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
fileID := c.Param("id")
|
||||
if fileID == "" {
|
||||
jsonError(c, common.CodeParamError, "file_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
versions, err := h.commitService.GetFileVersionHistory(fileID)
|
||||
if err != nil {
|
||||
jsonInternalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeSuccess,
|
||||
"data": versions,
|
||||
"message": common.CodeSuccess.Message(),
|
||||
})
|
||||
}
|
||||
413
internal/handler/file_commit_test.go
Normal file
413
internal/handler/file_commit_test.go
Normal file
@@ -0,0 +1,413 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// mockFileCommitSvc implements FileCommitServiceInterface for testing
|
||||
type mockFileCommitSvc struct {
|
||||
createCommitFn func(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error)
|
||||
listCommitsFn func(folderID string, page, pageSize int, orderBy string, desc bool) ([]*entity.FileCommit, int64, error)
|
||||
getCommitFn func(commitID string) (*entity.FileCommit, error)
|
||||
listCommitFilesFn func(commitID string) ([]*entity.FileCommitItem, error)
|
||||
diffCommitsFn func(fromID, toID string) ([]entity.DiffEntry, error)
|
||||
getUncommittedChangesFn func(folderID string) ([]entity.DiffEntry, error)
|
||||
getCommitTreeFn func(commitID string) (map[string]interface{}, error)
|
||||
getCommitFileContentFn func(folderID, commitID, fileID string) ([]byte, error)
|
||||
getFileVersionHistoryFn func(fileID string) ([]entity.VersionEntry, error)
|
||||
}
|
||||
|
||||
func (m *mockFileCommitSvc) CreateCommit(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error) {
|
||||
if m.createCommitFn != nil {
|
||||
return m.createCommitFn(folderID, authorID, message, changes)
|
||||
}
|
||||
return &entity.FileCommit{
|
||||
ID: "commit-1",
|
||||
FolderID: folderID,
|
||||
Message: message,
|
||||
AuthorID: authorID,
|
||||
FileCount: len(changes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockFileCommitSvc) ListCommits(folderID string, page, pageSize int, orderBy string, desc bool) ([]*entity.FileCommit, int64, error) {
|
||||
if m.listCommitsFn != nil {
|
||||
return m.listCommitsFn(folderID, page, pageSize, orderBy, desc)
|
||||
}
|
||||
now := int64(1718200000000)
|
||||
return []*entity.FileCommit{
|
||||
{ID: "c2", FolderID: folderID, Message: "second", AuthorID: "u1", FileCount: 1, BaseModel: entity.BaseModel{CreateTime: &now}},
|
||||
{ID: "c1", FolderID: folderID, Message: "first", AuthorID: "u1", FileCount: 2, BaseModel: entity.BaseModel{CreateTime: &now}},
|
||||
}, 2, nil
|
||||
}
|
||||
|
||||
func (m *mockFileCommitSvc) GetCommit(commitID string) (*entity.FileCommit, error) {
|
||||
if m.getCommitFn != nil {
|
||||
return m.getCommitFn(commitID)
|
||||
}
|
||||
return &entity.FileCommit{ID: commitID, FolderID: "folder-1", Message: "test commit", AuthorID: "u1", FileCount: 1}, nil
|
||||
}
|
||||
|
||||
func (m *mockFileCommitSvc) ListCommitFiles(commitID string) ([]*entity.FileCommitItem, error) {
|
||||
if m.listCommitFilesFn != nil {
|
||||
return m.listCommitFilesFn(commitID)
|
||||
}
|
||||
return []*entity.FileCommitItem{
|
||||
{ID: "i1", CommitID: commitID, FileID: "f1", Operation: "add"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockFileCommitSvc) DiffCommits(fromID, toID string) ([]entity.DiffEntry, error) {
|
||||
if m.diffCommitsFn != nil {
|
||||
return m.diffCommitsFn(fromID, toID)
|
||||
}
|
||||
return []entity.DiffEntry{
|
||||
{FileID: "f1", FileName: "file.txt", Operation: "modify"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockFileCommitSvc) GetUncommittedChanges(folderID string) ([]entity.DiffEntry, error) {
|
||||
if m.getUncommittedChangesFn != nil {
|
||||
return m.getUncommittedChangesFn(folderID)
|
||||
}
|
||||
return []entity.DiffEntry{
|
||||
{FileID: "f1", FileName: "new.txt", Operation: "add"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockFileCommitSvc) GetCommitTree(commitID string) (map[string]interface{}, error) {
|
||||
if m.getCommitTreeFn != nil {
|
||||
return m.getCommitTreeFn(commitID)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"f1": map[string]interface{}{"name": "file.txt", "hash": "abc123", "size": 100, "status": "1"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockFileCommitSvc) GetCommitFileContent(folderID, commitID, fileID string) ([]byte, error) {
|
||||
if m.getCommitFileContentFn != nil {
|
||||
return m.getCommitFileContentFn(folderID, commitID, fileID)
|
||||
}
|
||||
return []byte("file content"), nil
|
||||
}
|
||||
|
||||
func (m *mockFileCommitSvc) GetFileVersionHistory(fileID string) ([]entity.VersionEntry, error) {
|
||||
if m.getFileVersionHistoryFn != nil {
|
||||
return m.getFileVersionHistoryFn(fileID)
|
||||
}
|
||||
now := int64(1718200000000)
|
||||
return []entity.VersionEntry{
|
||||
{CommitID: "c2", Operation: "modify", Hash: "def456", CreateTime: &now, Message: "updated"},
|
||||
{CommitID: "c1", Operation: "add", Hash: "abc123", CreateTime: &now, Message: "initial"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func setupFileCommitTest(userID string) (*gin.Engine, *mockFileCommitSvc) {
|
||||
mock := &mockFileCommitSvc{}
|
||||
h := &FileCommitHandler{commitService: mock}
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("user", &entity.User{ID: userID})
|
||||
})
|
||||
r.POST("/api/v1/folders/:folder_id/commits", h.CreateCommit)
|
||||
r.GET("/api/v1/folders/:folder_id/commits", h.ListCommits)
|
||||
r.GET("/api/v1/folders/:folder_id/commits/:commit_id", h.GetCommit)
|
||||
r.GET("/api/v1/folders/:folder_id/commits/:commit_id/files", h.ListCommitFiles)
|
||||
r.GET("/api/v1/folders/:folder_id/commits/diff", h.DiffCommits)
|
||||
r.GET("/api/v1/folders/:folder_id/changes", h.GetUncommittedChanges)
|
||||
r.GET("/api/v1/folders/:folder_id/commits/:commit_id/tree", h.GetCommitTree)
|
||||
r.GET("/api/v1/folders/:folder_id/commits/:commit_id/files/:file_id/content", h.GetCommitFileContent)
|
||||
r.GET("/api/v1/files/:id/versions", h.GetFileVersionHistory)
|
||||
return r, mock
|
||||
}
|
||||
|
||||
func setupFileCommitTestNoAuth() *gin.Engine {
|
||||
h := &FileCommitHandler{}
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/folders/:folder_id/commits", h.CreateCommit)
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestFileCommit_CreateCommit_Success(t *testing.T) {
|
||||
r, mock := setupFileCommitTest("user-1")
|
||||
mock.createCommitFn = func(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error) {
|
||||
if folderID != "folder-1" {
|
||||
t.Errorf("expected folder-1, got %s", folderID)
|
||||
}
|
||||
if authorID != "user-1" {
|
||||
t.Errorf("expected user-1, got %s", authorID)
|
||||
}
|
||||
if message != "initial commit" {
|
||||
t.Errorf("expected 'initial commit', got %s", message)
|
||||
}
|
||||
if len(changes) != 1 || changes[0].FileID != "f1" {
|
||||
t.Errorf("unexpected changes: %+v", changes)
|
||||
}
|
||||
now := int64(1718200000000)
|
||||
return &entity.FileCommit{
|
||||
ID: "commit-1", FolderID: folderID, Message: message,
|
||||
AuthorID: authorID, FileCount: len(changes),
|
||||
BaseModel: entity.BaseModel{CreateTime: &now},
|
||||
}, nil
|
||||
}
|
||||
|
||||
body := `{"message": "initial commit", "files": [{"file_id": "f1", "file_name": "test.txt", "operation": "add", "content": "hello"}]}`
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/folders/folder-1/commits", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp["code"] != float64(common.CodeSuccess) {
|
||||
t.Errorf("expected code 0, got %v", resp["code"])
|
||||
}
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected data to be object, got %T", resp["data"])
|
||||
}
|
||||
if data["message"] != "initial commit" {
|
||||
t.Errorf("expected 'initial commit', got %v", data["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommit_CreateCommit_NoAuth(t *testing.T) {
|
||||
r := setupFileCommitTestNoAuth()
|
||||
body := `{"message": "test", "files": [{"file_id": "f1", "file_name": "t.txt", "operation": "add"}]}`
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/folders/folder-1/commits", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
// No auth middleware → code 401
|
||||
if code, _ := resp["code"].(float64); code != float64(common.CodeUnauthorized) {
|
||||
t.Errorf("expected unauthorized, got code %v", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommit_CreateCommit_InvalidJSON(t *testing.T) {
|
||||
r, _ := setupFileCommitTest("user-1")
|
||||
body := `{invalid json`
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/folders/folder-1/commits", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if code, _ := resp["code"].(float64); code != float64(common.CodeBadRequest) {
|
||||
t.Errorf("expected bad request, got code %v", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommit_ListCommits_Success(t *testing.T) {
|
||||
r, mock := setupFileCommitTest("user-1")
|
||||
mock.listCommitsFn = func(folderID string, page, pageSize int, orderBy string, desc bool) ([]*entity.FileCommit, int64, error) {
|
||||
if folderID != "folder-1" {
|
||||
t.Errorf("expected folder-1, got %s", folderID)
|
||||
}
|
||||
return []*entity.FileCommit{
|
||||
{ID: "c2", FolderID: folderID, Message: "second", AuthorID: "u1", FileCount: 1},
|
||||
{ID: "c1", FolderID: folderID, Message: "first", AuthorID: "u1", FileCount: 2},
|
||||
}, 2, nil
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/commits?page=1&page_size=10", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["code"] != float64(common.CodeSuccess) {
|
||||
t.Errorf("expected code 0, got %v", resp["code"])
|
||||
}
|
||||
data, _ := resp["data"].(map[string]interface{})
|
||||
if total, _ := data["total"].(float64); total != 2 {
|
||||
t.Errorf("expected total 2, got %v", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommit_GetCommit_Success(t *testing.T) {
|
||||
r, _ := setupFileCommitTest("user-1")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/commits/commit-1", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["code"] != float64(common.CodeSuccess) {
|
||||
t.Errorf("expected success, got code %v: %s", resp["code"], resp["message"])
|
||||
}
|
||||
data, _ := resp["data"].(map[string]interface{})
|
||||
if data["id"] != "commit-1" {
|
||||
t.Errorf("expected commit-1, got %v", data["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommit_GetCommit_NotFound(t *testing.T) {
|
||||
r, mock := setupFileCommitTest("user-1")
|
||||
mock.getCommitFn = func(commitID string) (*entity.FileCommit, error) {
|
||||
return nil, common.ErrNotFound
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/commits/missing", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if code, _ := resp["code"].(float64); code != float64(common.CodeNotFound) {
|
||||
t.Errorf("expected 404, got code %v", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommit_ListCommitFiles_Success(t *testing.T) {
|
||||
r, _ := setupFileCommitTest("user-1")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/commits/commit-1/files", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["code"] != float64(common.CodeSuccess) {
|
||||
t.Errorf("expected success, got code %v", resp["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommit_DiffCommits_Success(t *testing.T) {
|
||||
r, _ := setupFileCommitTest("user-1")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/commits/diff?from=c1&to=c2", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["code"] != float64(common.CodeSuccess) {
|
||||
t.Errorf("expected success, got code %v", resp["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommit_DiffCommits_MissingParams(t *testing.T) {
|
||||
r, _ := setupFileCommitTest("user-1")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/commits/diff", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if code, _ := resp["code"].(float64); code != float64(common.CodeParamError) {
|
||||
t.Errorf("expected param error, got code %v", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommit_GetUncommittedChanges_Success(t *testing.T) {
|
||||
r, _ := setupFileCommitTest("user-1")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/changes", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["code"] != float64(common.CodeSuccess) {
|
||||
t.Errorf("expected success, got code %v", resp["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommit_GetCommitTree_Success(t *testing.T) {
|
||||
r, _ := setupFileCommitTest("user-1")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/commits/commit-1/tree", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["code"] != float64(common.CodeSuccess) {
|
||||
t.Errorf("expected success, got code %v", resp["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommit_GetCommitFileContent_Success(t *testing.T) {
|
||||
r, mock := setupFileCommitTest("user-1")
|
||||
mock.getCommitFileContentFn = func(folderID, commitID, fileID string) ([]byte, error) {
|
||||
return []byte("hello world"), nil
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/commits/commit-1/files/f1/content", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["code"] != float64(common.CodeSuccess) {
|
||||
t.Errorf("expected success, got code %v", resp["code"])
|
||||
}
|
||||
data, _ := resp["data"].(map[string]interface{})
|
||||
if content, _ := data["content"].(string); content != "hello world" {
|
||||
t.Errorf("expected 'hello world', got %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommit_GetFileVersionHistory_Success(t *testing.T) {
|
||||
r, _ := setupFileCommitTest("user-1")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/files/f1/versions", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["code"] != float64(common.CodeSuccess) {
|
||||
t.Errorf("expected success, got code %v", resp["code"])
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ type Router struct {
|
||||
difyRetrievalHandler *handler.DifyRetrievalHandler
|
||||
pluginHandler *handler.PluginHandler
|
||||
modelHandler *handler.ModelHandler
|
||||
fileCommitHandler *handler.FileCommitHandler
|
||||
adminRuntimeHandler *handler.AdminRuntimeHandler
|
||||
}
|
||||
|
||||
@@ -74,6 +75,7 @@ func NewRouter(
|
||||
difyRetrievalHandler *handler.DifyRetrievalHandler,
|
||||
pluginHandler *handler.PluginHandler,
|
||||
modelHandler *handler.ModelHandler,
|
||||
fileCommitHandler *handler.FileCommitHandler,
|
||||
adminRuntimeHandler *handler.AdminRuntimeHandler,
|
||||
) *Router {
|
||||
return &Router{
|
||||
@@ -100,6 +102,7 @@ func NewRouter(
|
||||
difyRetrievalHandler: difyRetrievalHandler,
|
||||
pluginHandler: pluginHandler,
|
||||
modelHandler: modelHandler,
|
||||
fileCommitHandler: fileCommitHandler,
|
||||
adminRuntimeHandler: adminRuntimeHandler,
|
||||
}
|
||||
}
|
||||
@@ -304,8 +307,51 @@ func (r *Router) Setup(engine *gin.Engine) {
|
||||
file.GET("/:id/ancestors", r.fileHandler.GetFileAncestors)
|
||||
file.GET("/:id/parent", r.fileHandler.GetParentFolder)
|
||||
file.GET("/:id", r.fileHandler.Download)
|
||||
file.GET("/:id/versions", r.fileCommitHandler.GetFileVersionHistory)
|
||||
}
|
||||
|
||||
// File commit routes — /folders/ takes folder_id directly
|
||||
commitFolders := v1.Group("/folders")
|
||||
{
|
||||
commitFolders.POST("/:folder_id/commits", r.fileCommitHandler.CreateCommit)
|
||||
commitFolders.GET("/:folder_id/commits", r.fileCommitHandler.ListCommits)
|
||||
commitFolders.GET("/:folder_id/commits/diff", r.fileCommitHandler.DiffCommits)
|
||||
commitFolders.GET("/:folder_id/commits/:commit_id", r.fileCommitHandler.GetCommit)
|
||||
commitFolders.GET("/:folder_id/commits/:commit_id/files", r.fileCommitHandler.ListCommitFiles)
|
||||
commitFolders.GET("/:folder_id/commits/:commit_id/tree", r.fileCommitHandler.GetCommitTree)
|
||||
commitFolders.GET("/:folder_id/commits/:commit_id/files/:file_id/content", r.fileCommitHandler.GetCommitFileContent)
|
||||
commitFolders.GET("/:folder_id/changes", r.fileCommitHandler.GetUncommittedChanges)
|
||||
}
|
||||
|
||||
// /workspace/{workspace_id}/commits — alias for /folders/ (workspace_id == folder_id)
|
||||
commitWorkspace := v1.Group("/workspace")
|
||||
{
|
||||
commitWorkspace.POST("/:folder_id/commits", r.fileCommitHandler.CreateCommit)
|
||||
commitWorkspace.GET("/:folder_id/commits", r.fileCommitHandler.ListCommits)
|
||||
commitWorkspace.GET("/:folder_id/commits/diff", r.fileCommitHandler.DiffCommits)
|
||||
commitWorkspace.GET("/:folder_id/commits/:commit_id", r.fileCommitHandler.GetCommit)
|
||||
commitWorkspace.GET("/:folder_id/commits/:commit_id/files", r.fileCommitHandler.ListCommitFiles)
|
||||
commitWorkspace.GET("/:folder_id/commits/:commit_id/tree", r.fileCommitHandler.GetCommitTree)
|
||||
commitWorkspace.GET("/:folder_id/commits/:commit_id/files/:file_id/content", r.fileCommitHandler.GetCommitFileContent)
|
||||
commitWorkspace.GET("/:folder_id/changes", r.fileCommitHandler.GetUncommittedChanges)
|
||||
}
|
||||
|
||||
// /datasets/{dataset_id}/commits — resolve dataset_id → folder_id via middleware
|
||||
commitDatasets := v1.Group("/datasets/:dataset_id")
|
||||
commitDatasets.Use(handler.CommitFolderResolver(r.fileCommitHandler, "datasets", "dataset_id"))
|
||||
{
|
||||
commitDatasets.POST("/commits", r.fileCommitHandler.CreateCommit)
|
||||
commitDatasets.GET("/commits", r.fileCommitHandler.ListCommits)
|
||||
commitDatasets.GET("/commits/diff", r.fileCommitHandler.DiffCommits)
|
||||
commitDatasets.GET("/commits/:commit_id", r.fileCommitHandler.GetCommit)
|
||||
commitDatasets.GET("/commits/:commit_id/files", r.fileCommitHandler.ListCommitFiles)
|
||||
commitDatasets.GET("/commits/:commit_id/tree", r.fileCommitHandler.GetCommitTree)
|
||||
commitDatasets.GET("/commits/:commit_id/files/:file_id/content", r.fileCommitHandler.GetCommitFileContent)
|
||||
commitDatasets.GET("/changes", r.fileCommitHandler.GetUncommittedChanges)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Author routes
|
||||
authors := v1.Group("/authors")
|
||||
{
|
||||
|
||||
637
internal/service/file_commit.go
Normal file
637
internal/service/file_commit.go
Normal file
@@ -0,0 +1,637 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/storage"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FileCommitService file commit service
|
||||
type FileCommitService struct {
|
||||
commitDAO *dao.FileCommitDAO
|
||||
commitItemDAO *dao.FileCommitItemDAO
|
||||
fileDAO *dao.FileDAO
|
||||
}
|
||||
|
||||
// NewFileCommitService create file commit service
|
||||
func NewFileCommitService() *FileCommitService {
|
||||
return &FileCommitService{
|
||||
commitDAO: dao.NewFileCommitDAO(),
|
||||
commitItemDAO: dao.NewFileCommitItemDAO(),
|
||||
fileDAO: dao.NewFileDAO(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCommit creates a new commit for a workspace folder
|
||||
func (s *FileCommitService) CreateCommit(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error) {
|
||||
// 1. Get the latest commit for this folder
|
||||
latestCommit, _ := s.commitDAO.GetLatestByFolderID(folderID)
|
||||
|
||||
// 2. Build tree state from latest commit
|
||||
treeState := make(map[string]interface{})
|
||||
if latestCommit != nil && latestCommit.TreeState != nil {
|
||||
if err := json.Unmarshal([]byte(*latestCommit.TreeState), &treeState); err != nil {
|
||||
common.Warn("failed to unmarshal previous tree state", zap.Error(err))
|
||||
treeState = make(map[string]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create commit record
|
||||
commitID := generateCommitUUID()
|
||||
nowMs := time.Now().UnixMilli()
|
||||
|
||||
commit := &entity.FileCommit{
|
||||
ID: commitID,
|
||||
FolderID: folderID,
|
||||
Message: message,
|
||||
AuthorID: authorID,
|
||||
FileCount: len(changes),
|
||||
}
|
||||
|
||||
if latestCommit != nil {
|
||||
parentID := latestCommit.ID
|
||||
commit.ParentID = &parentID
|
||||
}
|
||||
|
||||
// All DB operations run inside a single transaction.
|
||||
var treeStr string
|
||||
if err := dao.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// Save commit
|
||||
if err := tx.Create(commit).Error; err != nil {
|
||||
return fmt.Errorf("failed to create commit: %w", err)
|
||||
}
|
||||
|
||||
// Backfill parent_id for existing tree_state entries
|
||||
for fid, entry := range treeState {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if _, has := m["parent_id"]; !has {
|
||||
var fileRec entity.File
|
||||
if err := tx.Select("parent_id").Where("id = ?", fid).First(&fileRec).Error; err == nil {
|
||||
m["parent_id"] = fileRec.ParentID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
storageImpl := storage.GetStorageFactory().GetStorage()
|
||||
|
||||
for _, change := range changes {
|
||||
item := &entity.FileCommitItem{
|
||||
ID: generateCommitUUID(),
|
||||
CommitID: commitID,
|
||||
FileID: change.FileID,
|
||||
Operation: change.Operation,
|
||||
}
|
||||
|
||||
switch change.Operation {
|
||||
case "add", "modify":
|
||||
contentBytes := []byte(change.Content)
|
||||
hash := sha256.Sum256(contentBytes)
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
objKey := ".objects/" + hashHex
|
||||
|
||||
if storageImpl != nil {
|
||||
if err := storageImpl.Put(folderID, objKey, contentBytes); err != nil {
|
||||
return fmt.Errorf("failed to store object: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if change.Operation == "modify" {
|
||||
if oldEntry, ok := treeState[change.FileID]; ok {
|
||||
if oldMap, ok := oldEntry.(map[string]interface{}); ok {
|
||||
if oldHash, ok := oldMap["hash"].(string); ok {
|
||||
item.OldHash = &oldHash
|
||||
}
|
||||
if oldLoc, ok := oldMap["location"].(string); ok {
|
||||
item.OldLocation = &oldLoc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item.NewHash = &hashHex
|
||||
item.NewLocation = &objKey
|
||||
|
||||
fSize := int64(len(contentBytes))
|
||||
if err := tx.Model(&entity.File{}).Where("id = ?", change.FileID).Updates(map[string]interface{}{
|
||||
"location": objKey,
|
||||
"size": fSize,
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("failed to update file record: %w", err)
|
||||
}
|
||||
|
||||
// Look up parent_id from the File table
|
||||
fileParentID := ""
|
||||
var fileRec entity.File
|
||||
if err := tx.Select("parent_id").Where("id = ?", change.FileID).First(&fileRec).Error; err == nil {
|
||||
fileParentID = fileRec.ParentID
|
||||
}
|
||||
|
||||
treeState[change.FileID] = map[string]interface{}{
|
||||
"hash": hashHex,
|
||||
"location": objKey,
|
||||
"name": change.FileName,
|
||||
"size": fSize,
|
||||
"status": "1",
|
||||
"parent_id": fileParentID,
|
||||
}
|
||||
|
||||
case "delete":
|
||||
if oldEntry, ok := treeState[change.FileID]; ok {
|
||||
if oldMap, ok := oldEntry.(map[string]interface{}); ok {
|
||||
if oldHash, ok := oldMap["hash"].(string); ok {
|
||||
item.OldHash = &oldHash
|
||||
}
|
||||
if oldLoc, ok := oldMap["location"].(string); ok {
|
||||
item.OldLocation = &oldLoc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&entity.File{}).Where("id = ?", change.FileID).Update("status", "0").Error; err != nil {
|
||||
return fmt.Errorf("failed to soft-delete file: %w", err)
|
||||
}
|
||||
|
||||
if entry, ok := treeState[change.FileID]; ok {
|
||||
if entryMap, ok := entry.(map[string]interface{}); ok {
|
||||
entryMap["status"] = "0"
|
||||
}
|
||||
}
|
||||
|
||||
case "rename":
|
||||
item.OldName = &change.OldName
|
||||
item.NewName = &change.NewName
|
||||
|
||||
if err := tx.Model(&entity.File{}).Where("id = ?", change.FileID).Update("name", change.NewName).Error; err != nil {
|
||||
return fmt.Errorf("failed to rename file: %w", err)
|
||||
}
|
||||
|
||||
if entry, ok := treeState[change.FileID]; ok {
|
||||
if entryMap, ok := entry.(map[string]interface{}); ok {
|
||||
entryMap["name"] = change.NewName
|
||||
}
|
||||
} else {
|
||||
treeState[change.FileID] = map[string]interface{}{
|
||||
"name": change.NewName,
|
||||
"status": "1",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save commit item
|
||||
if err := tx.Create(item).Error; err != nil {
|
||||
return fmt.Errorf("failed to create commit item: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize and save tree state
|
||||
treeJSON, err := json.Marshal(treeState)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal tree state: %w", err)
|
||||
}
|
||||
if err := tx.Model(&entity.FileCommit{}).Where("id = ?", commitID).Update("tree_state", string(treeJSON)).Error; err != nil {
|
||||
return fmt.Errorf("failed to update tree state: %w", err)
|
||||
}
|
||||
treeStr = string(treeJSON)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commit.TreeState = &treeStr
|
||||
commit.CreateTime = &nowMs
|
||||
|
||||
return commit, nil
|
||||
}
|
||||
|
||||
// ListCommits lists commits for a workspace folder with pagination
|
||||
func (s *FileCommitService) ListCommits(folderID string, page, pageSize int, orderBy string, desc bool) ([]*entity.FileCommit, int64, error) {
|
||||
return s.commitDAO.ListByFolderID(folderID, page, pageSize, orderBy, desc)
|
||||
}
|
||||
|
||||
// GetCommit gets a single commit by ID
|
||||
func (s *FileCommitService) GetCommit(commitID string) (*entity.FileCommit, error) {
|
||||
return s.commitDAO.GetByID(commitID)
|
||||
}
|
||||
|
||||
// ListCommitFiles lists all file change items for a commit
|
||||
func (s *FileCommitService) ListCommitFiles(commitID string) ([]*entity.FileCommitItem, error) {
|
||||
return s.commitItemDAO.ListByCommitID(commitID)
|
||||
}
|
||||
|
||||
// DiffCommits compares two commits and returns the diff
|
||||
func (s *FileCommitService) DiffCommits(fromID, toID string) ([]entity.DiffEntry, error) {
|
||||
fromItems, err := s.commitItemDAO.ListByCommitID(fromID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toItems, err := s.commitItemDAO.ListByCommitID(toID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fromMap := make(map[string]*entity.FileCommitItem)
|
||||
for _, item := range fromItems {
|
||||
fromMap[item.FileID] = item
|
||||
}
|
||||
toMap := make(map[string]*entity.FileCommitItem)
|
||||
for _, item := range toItems {
|
||||
toMap[item.FileID] = item
|
||||
}
|
||||
|
||||
// Get tree state for file names (use to commit)
|
||||
toCommit, err := s.commitDAO.GetByID(toID)
|
||||
treeState := make(map[string]interface{})
|
||||
if err == nil && toCommit != nil && toCommit.TreeState != nil {
|
||||
json.Unmarshal([]byte(*toCommit.TreeState), &treeState)
|
||||
}
|
||||
|
||||
getFileName := func(fileID string) string {
|
||||
if entry, ok := treeState[fileID]; ok {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if name, ok := m["name"].(string); ok {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
return fileID
|
||||
}
|
||||
|
||||
allFileIDs := make(map[string]bool)
|
||||
for fid := range fromMap {
|
||||
allFileIDs[fid] = true
|
||||
}
|
||||
for fid := range toMap {
|
||||
allFileIDs[fid] = true
|
||||
}
|
||||
|
||||
// Sort for deterministic output
|
||||
var sortedIDs []string
|
||||
for fid := range allFileIDs {
|
||||
sortedIDs = append(sortedIDs, fid)
|
||||
}
|
||||
sort.Strings(sortedIDs)
|
||||
|
||||
var diff []entity.DiffEntry
|
||||
for _, fid := range sortedIDs {
|
||||
fromItem := fromMap[fid]
|
||||
toItem := toMap[fid]
|
||||
|
||||
var entry entity.DiffEntry
|
||||
entry.FileID = fid
|
||||
entry.FileName = getFileName(fid)
|
||||
|
||||
if fromItem != nil && toItem == nil {
|
||||
// Deleted
|
||||
entry.Operation = "delete"
|
||||
entry.OldHash = fromItem.NewHash
|
||||
entry.OldLocation = fromItem.NewLocation
|
||||
} else if fromItem == nil && toItem != nil {
|
||||
// Added
|
||||
entry.Operation = "add"
|
||||
entry.NewHash = toItem.NewHash
|
||||
entry.NewLocation = toItem.NewLocation
|
||||
} else {
|
||||
// Both exist — compare hashes
|
||||
fromHash := ""
|
||||
if fromItem.NewHash != nil {
|
||||
fromHash = *fromItem.NewHash
|
||||
}
|
||||
toHash := ""
|
||||
if toItem.NewHash != nil {
|
||||
toHash = *toItem.NewHash
|
||||
}
|
||||
if fromHash != toHash {
|
||||
entry.Operation = "modify"
|
||||
entry.OldHash = fromItem.NewHash
|
||||
entry.OldLocation = fromItem.NewLocation
|
||||
entry.NewHash = toItem.NewHash
|
||||
entry.NewLocation = toItem.NewLocation
|
||||
}
|
||||
}
|
||||
|
||||
if entry.Operation != "" {
|
||||
diff = append(diff, entry)
|
||||
}
|
||||
}
|
||||
|
||||
return diff, nil
|
||||
}
|
||||
|
||||
// GetUncommittedChanges gets uncommitted changes for a workspace folder.
|
||||
// Recursively scans all sub-folders.
|
||||
func (s *FileCommitService) GetUncommittedChanges(folderID string) ([]entity.DiffEntry, error) {
|
||||
// Get latest commit tree state
|
||||
latest, err := s.commitDAO.GetLatestByFolderID(folderID)
|
||||
committedFiles := make(map[string]map[string]interface{})
|
||||
if err == nil && latest != nil && latest.TreeState != nil {
|
||||
var treeData map[string]interface{}
|
||||
if jsonErr := json.Unmarshal([]byte(*latest.TreeState), &treeData); jsonErr == nil {
|
||||
for k, v := range treeData {
|
||||
if m, ok := v.(map[string]interface{}); ok {
|
||||
committedFiles[k] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all live files recursively under this folder
|
||||
liveMap := s.collectAllFilesRecursive(folderID)
|
||||
|
||||
var changes []entity.DiffEntry
|
||||
processed := make(map[string]bool)
|
||||
|
||||
// Check committed files for modifications and deletions
|
||||
for fid, committedEntry := range committedFiles {
|
||||
processed[fid] = true
|
||||
if committedEntry["status"] == "0" {
|
||||
continue
|
||||
}
|
||||
|
||||
if liveFile, ok := liveMap[fid]; ok {
|
||||
liveHash := computeLiveFileHash(folderID, fid, liveFile)
|
||||
committedHash := ""
|
||||
if h, ok := committedEntry["hash"].(string); ok {
|
||||
committedHash = h
|
||||
}
|
||||
if liveHash != "" && liveHash != committedHash {
|
||||
changes = append(changes, entity.DiffEntry{
|
||||
FileID: fid,
|
||||
FileName: liveFile.Name,
|
||||
Operation: "modify",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
name := ""
|
||||
if n, ok := committedEntry["name"].(string); ok {
|
||||
name = n
|
||||
}
|
||||
changes = append(changes, entity.DiffEntry{
|
||||
FileID: fid,
|
||||
FileName: name,
|
||||
Operation: "delete",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check for newly added files
|
||||
for _, liveFile := range liveMap {
|
||||
if !processed[liveFile.ID] {
|
||||
changes = append(changes, entity.DiffEntry{
|
||||
FileID: liveFile.ID,
|
||||
FileName: liveFile.Name,
|
||||
Operation: "add",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
// collectAllFilesRecursive recursively collects all non-folder files under a folder.
|
||||
func (s *FileCommitService) collectAllFilesRecursive(folderID string) map[string]*entity.File {
|
||||
result := make(map[string]*entity.File)
|
||||
// Direct files (non-folder)
|
||||
files, _ := s.fileDAO.ListNonFolderByParentID(folderID)
|
||||
for _, f := range files {
|
||||
result[f.ID] = f
|
||||
}
|
||||
// Sub-folders — recurse
|
||||
subFolders, _ := s.fileDAO.ListFolderByParentID(folderID)
|
||||
for _, sf := range subFolders {
|
||||
sub := s.collectAllFilesRecursive(sf.ID)
|
||||
for k, v := range sub {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetCommitTree gets the tree state snapshot for a commit as a hierarchical tree.
|
||||
func (s *FileCommitService) GetCommitTree(commitID string) (map[string]interface{}, error) {
|
||||
commit, err := s.commitDAO.GetByID(commitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if commit.TreeState == nil {
|
||||
return map[string]interface{}{"id": commit.FolderID, "name": "", "type": "folder", "children": []interface{}{}}, nil
|
||||
}
|
||||
var flat map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(*commit.TreeState), &flat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.buildHierarchicalTree(flat, commit.FolderID), nil
|
||||
}
|
||||
|
||||
// buildHierarchicalTree builds a recursive tree from a flat tree_state map.
|
||||
// Sub-folder hierarchy is resolved from the File table's parent_id.
|
||||
func (s *FileCommitService) buildHierarchicalTree(flat map[string]interface{}, rootFolderID string) map[string]interface{} {
|
||||
// Collect all unique folder IDs
|
||||
folderIDs := map[string]bool{rootFolderID: true}
|
||||
for _, v := range flat {
|
||||
if entry, ok := v.(map[string]interface{}); ok {
|
||||
pid, _ := entry["parent_id"].(string)
|
||||
if pid == "" {
|
||||
pid = rootFolderID
|
||||
}
|
||||
folderIDs[pid] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Build folder parent map from File table
|
||||
folderParentMap := make(map[string]string)
|
||||
for fid := range folderIDs {
|
||||
if fid != rootFolderID {
|
||||
if f, err := s.fileDAO.GetByID(fid); err == nil {
|
||||
folderParentMap[fid] = f.ParentID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group file entries by parent_id
|
||||
filesByParent := make(map[string][]string)
|
||||
fileEntries := make(map[string]map[string]interface{})
|
||||
for fid, v := range flat {
|
||||
entry, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pid, _ := entry["parent_id"].(string)
|
||||
if pid == "" {
|
||||
pid = rootFolderID
|
||||
}
|
||||
filesByParent[pid] = append(filesByParent[pid], fid)
|
||||
fileEntries[fid] = entry
|
||||
}
|
||||
|
||||
// Group sub-folders by their parent
|
||||
childrenByFolder := make(map[string][]string)
|
||||
for sfid, ppid := range folderParentMap {
|
||||
childrenByFolder[ppid] = append(childrenByFolder[ppid], sfid)
|
||||
}
|
||||
|
||||
var buildNode func(nodeID string) map[string]interface{}
|
||||
buildNode = func(nodeID string) map[string]interface{} {
|
||||
nodeName := nodeID
|
||||
if f, err := s.fileDAO.GetByID(nodeID); err == nil {
|
||||
nodeName = f.Name
|
||||
}
|
||||
node := map[string]interface{}{
|
||||
"id": nodeID,
|
||||
"name": nodeName,
|
||||
"type": "folder",
|
||||
"children": []interface{}{},
|
||||
}
|
||||
|
||||
// File children
|
||||
for _, fid := range filesByParent[nodeID] {
|
||||
entry := fileEntries[fid]
|
||||
fn := map[string]interface{}{
|
||||
"id": fid,
|
||||
"name": entry["name"],
|
||||
"type": "file",
|
||||
"hash": entry["hash"],
|
||||
"size": entry["size"],
|
||||
"status": entry["status"],
|
||||
}
|
||||
if loc, ok := entry["location"].(string); ok && loc != "" {
|
||||
fn["location"] = loc
|
||||
}
|
||||
node["children"] = append(node["children"].([]interface{}), fn)
|
||||
}
|
||||
// Sub-folder children
|
||||
for _, sfid := range childrenByFolder[nodeID] {
|
||||
child := buildNode(sfid)
|
||||
node["children"] = append(node["children"].([]interface{}), child)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
return buildNode(rootFolderID)
|
||||
}
|
||||
|
||||
// GetCommitFileContent gets file content as it existed in a given commit
|
||||
func (s *FileCommitService) GetCommitFileContent(folderID, commitID, fileID string) ([]byte, error) {
|
||||
_, err := s.commitDAO.GetByID(commitID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commit not found: %w", err)
|
||||
}
|
||||
|
||||
item, err := s.commitItemDAO.GetByCommitIDAndFileID(commitID, fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file not found in commit: %w", err)
|
||||
}
|
||||
|
||||
if item.NewHash == nil && item.OldHash == nil {
|
||||
return nil, fmt.Errorf("file has no content in this commit")
|
||||
}
|
||||
|
||||
hash := ""
|
||||
if item.NewHash != nil {
|
||||
hash = *item.NewHash
|
||||
} else if item.OldHash != nil {
|
||||
hash = *item.OldHash
|
||||
}
|
||||
|
||||
objKey := ".objects/" + hash
|
||||
|
||||
storageImpl := storage.GetStorageFactory().GetStorage()
|
||||
if storageImpl == nil {
|
||||
return nil, fmt.Errorf("storage not initialized")
|
||||
}
|
||||
|
||||
blob, err := storageImpl.Get(folderID, objKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file content from storage: %w", err)
|
||||
}
|
||||
|
||||
return blob, nil
|
||||
}
|
||||
|
||||
// GetFileVersionHistory gets version history for a specific file
|
||||
func (s *FileCommitService) GetFileVersionHistory(fileID string) ([]entity.VersionEntry, error) {
|
||||
items, err := s.commitItemDAO.ListByFileID(fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var versions []entity.VersionEntry
|
||||
for _, item := range items {
|
||||
commit, err := s.commitDAO.GetByID(item.CommitID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
h := ""
|
||||
if item.NewHash != nil {
|
||||
h = *item.NewHash
|
||||
} else if item.OldHash != nil {
|
||||
h = *item.OldHash
|
||||
}
|
||||
|
||||
versions = append(versions, entity.VersionEntry{
|
||||
CommitID: item.CommitID,
|
||||
Operation: item.Operation,
|
||||
Hash: h,
|
||||
CreateTime: commit.CreateTime,
|
||||
Message: commit.Message,
|
||||
})
|
||||
}
|
||||
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// computeLiveFileHash computes the SHA256 hash of current file content from storage
|
||||
func computeLiveFileHash(folderID, fileID string, file *entity.File) string {
|
||||
if file.Location == nil || *file.Location == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
storageImpl := storage.GetStorageFactory().GetStorage()
|
||||
if storageImpl == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
data, err := storageImpl.Get(folderID, *file.Location)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
hash := sha256.Sum256(data)
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// generateCommitUUID generates a UUID without dashes
|
||||
func generateCommitUUID() string {
|
||||
id := uuid.New().String()
|
||||
return strings.ReplaceAll(id, "-", "")
|
||||
}
|
||||
Reference in New Issue
Block a user