Skip to content

Commit 1995e2c

Browse files
committed
Git fast-import based update repository files
1 parent 44ece1e commit 1995e2c

File tree

5 files changed

+596
-45
lines changed

5 files changed

+596
-45
lines changed

services/repository/files/content_test.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,6 @@ import (
2020
"github.com/stretchr/testify/require"
2121
)
2222

23-
func TestMain(m *testing.M) {
24-
unittest.MainTest(m)
25-
}
26-
2723
func getExpectedReadmeContentsResponse() *api.ContentsResponse {
2824
treePath := "README.md"
2925
sha := "4b4851ad51df6a7d9f25c979345979eaeb5b349f"
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package files
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"fmt"
10+
"io"
11+
"os"
12+
"time"
13+
14+
user_model "code.gitea.io/gitea/models/user"
15+
"code.gitea.io/gitea/modules/git"
16+
"code.gitea.io/gitea/modules/tempdir"
17+
"code.gitea.io/gitea/modules/util"
18+
)
19+
20+
// IdentityOptions for a person's identity like an author or committer
21+
type IdentityOptions struct {
22+
GitUserName string // to match "git config user.name"
23+
GitUserEmail string // to match "git config user.email"
24+
}
25+
26+
// CommitDateOptions store dates for GIT_AUTHOR_DATE and GIT_COMMITTER_DATE
27+
type CommitDateOptions struct {
28+
Author time.Time
29+
Committer time.Time
30+
}
31+
32+
type ChangeRepoFile struct {
33+
Operation string // "create", "update", or "delete"
34+
TreePath string
35+
FromTreePath string
36+
ContentReader io.ReadSeeker
37+
FileSize int64
38+
SHA string
39+
Options *RepoFileOptions
40+
}
41+
42+
// ChangeRepoFilesOptions holds the repository files update options
43+
type ChangeRepoFilesOptions struct {
44+
LastCommitID string
45+
OldBranch string
46+
NewBranch string
47+
Message string
48+
Files []*ChangeRepoFile
49+
Author *IdentityOptions
50+
Committer *IdentityOptions
51+
Dates *CommitDateOptions
52+
Signoff bool
53+
}
54+
55+
type RepoFileOptions struct {
56+
treePath string
57+
fromTreePath string
58+
executable bool
59+
}
60+
61+
// UpdateRepoBranch updates the specified branch in the given repository with the provided file changes.
62+
// It uses the fast-import command to perform the update efficiently. So that we can avoid to clone the whole repo.
63+
// TODO: add support for LFS
64+
// TODO: add support for GPG signing
65+
func UpdateRepoBranch(ctx context.Context, doer *user_model.User, repoPath string, opts ChangeRepoFilesOptions) error {
66+
fPath, cancel, err := generateFastImportFile(doer, opts)
67+
if err != nil {
68+
return err
69+
}
70+
defer func() {
71+
cancel()
72+
}()
73+
74+
f, err := os.Open(fPath)
75+
if err != nil {
76+
return err
77+
}
78+
defer f.Close()
79+
80+
_, _, err = git.NewCommand("fast-import").
81+
RunStdString(ctx, &git.RunOpts{
82+
Stdin: f,
83+
Dir: repoPath,
84+
})
85+
return err
86+
}
87+
88+
const commitFileHead = `commit refs/heads/%s
89+
author %s <%s> %d %s
90+
committer %s <%s> %d %s
91+
data %d
92+
%s
93+
94+
%s`
95+
96+
func getReadSeekerSize(r io.ReadSeeker) (int64, error) {
97+
if file, ok := r.(*os.File); ok {
98+
stat, err := file.Stat()
99+
if err != nil {
100+
return 0, err
101+
}
102+
return stat.Size(), nil
103+
}
104+
105+
size, err := r.Seek(0, io.SeekEnd)
106+
if err != nil {
107+
return 0, err
108+
}
109+
if _, err := r.Seek(0, io.SeekStart); err != nil {
110+
return 0, err
111+
}
112+
return size, nil
113+
}
114+
115+
func getZoneOffsetStr(t time.Time) string {
116+
// Get the timezone offset in hours and minutes
117+
_, offset := t.Zone()
118+
return fmt.Sprintf("%+03d%02d", offset/3600, (offset%3600)/60)
119+
}
120+
121+
func writeCommit(f io.Writer, doer *user_model.User, opts *ChangeRepoFilesOptions) error {
122+
_, err := fmt.Fprintf(f, "commit refs/heads/%s\n", util.Iif(opts.NewBranch != "", opts.NewBranch, opts.OldBranch))
123+
return err
124+
}
125+
126+
func writeAuthor(f io.Writer, doer *user_model.User, opts *ChangeRepoFilesOptions) error {
127+
_, err := fmt.Fprintf(f, "author %s <%s> %d %s\n",
128+
opts.Author.GitUserName,
129+
opts.Author.GitUserEmail,
130+
opts.Dates.Author.Unix(),
131+
getZoneOffsetStr(opts.Dates.Author))
132+
return err
133+
}
134+
135+
func writeCommitter(f io.Writer, doer *user_model.User, opts *ChangeRepoFilesOptions) error {
136+
_, err := fmt.Fprintf(f, "committer %s <%s> %d %s\n",
137+
opts.Committer.GitUserName,
138+
opts.Committer.GitUserEmail,
139+
opts.Dates.Committer.Unix(),
140+
getZoneOffsetStr(opts.Dates.Committer))
141+
return err
142+
}
143+
144+
func writeMessage(f io.Writer, doer *user_model.User, opts *ChangeRepoFilesOptions) error {
145+
messageBytes := new(bytes.Buffer)
146+
if _, err := messageBytes.WriteString(opts.Message); err != nil {
147+
return err
148+
}
149+
150+
committerSig := makeGitUserSignature(doer, opts.Committer, opts.Author)
151+
152+
if opts.Signoff {
153+
// Signed-off-by
154+
_, _ = messageBytes.WriteString("\n")
155+
_, _ = messageBytes.WriteString("Signed-off-by: ")
156+
_, _ = messageBytes.WriteString(committerSig.String())
157+
}
158+
_, err := fmt.Fprintf(f, "data %d\n%s\n", messageBytes.Len()+1, messageBytes.String())
159+
return err
160+
}
161+
162+
func writeFrom(f io.Writer, doer *user_model.User, opts *ChangeRepoFilesOptions) error {
163+
var fromStatement string
164+
if opts.LastCommitID != "" && opts.LastCommitID != "HEAD" {
165+
fromStatement = fmt.Sprintf("from %s\n", opts.LastCommitID)
166+
} else if opts.OldBranch != "" {
167+
fromStatement = fmt.Sprintf("from refs/heads/%s^0\n", opts.OldBranch)
168+
} // if this is a new branch, so we cannot add from refs/heads/newbranch^0
169+
170+
if len(fromStatement) == 0 {
171+
return nil
172+
}
173+
_, err := fmt.Fprint(f, fromStatement)
174+
return err
175+
}
176+
177+
// generateFastImportFile generates a fast-import file based on the provided options.
178+
func generateFastImportFile(doer *user_model.User, opts ChangeRepoFilesOptions) (fPath string, cancel func(), err error) {
179+
if opts.OldBranch == "" && opts.NewBranch == "" {
180+
return "", nil, fmt.Errorf("both old and new branches are empty")
181+
}
182+
if opts.OldBranch == opts.NewBranch {
183+
opts.NewBranch = ""
184+
}
185+
186+
writeFuncs := []func(io.Writer, *user_model.User, *ChangeRepoFilesOptions) error{
187+
writeCommit,
188+
writeAuthor,
189+
writeCommitter,
190+
writeMessage,
191+
// TODO: add support for Gpg signing
192+
writeFrom,
193+
}
194+
195+
f, cancel, err := tempdir.OsTempDir("gitea-fast-import-").CreateTempFileRandom("fast-import-*.txt")
196+
if err != nil {
197+
return "", nil, err
198+
}
199+
defer func() {
200+
if err != nil {
201+
cancel()
202+
}
203+
}()
204+
205+
for _, writeFunc := range writeFuncs {
206+
if err := writeFunc(f, doer, &opts); err != nil {
207+
return "", nil, err
208+
}
209+
}
210+
211+
// Write the file changes to the fast-import file
212+
for _, file := range opts.Files {
213+
switch file.Operation {
214+
case "create", "update":
215+
// delete the old file if it exists
216+
if file.FromTreePath != file.TreePath && file.FromTreePath != "" {
217+
if _, err := fmt.Fprintf(f, "D %s\n", file.FromTreePath); err != nil {
218+
return "", nil, err
219+
}
220+
}
221+
222+
fileMask := "100644"
223+
if file.Options != nil && file.Options.executable {
224+
fileMask = "100755"
225+
}
226+
227+
if _, err := fmt.Fprintf(f, "M %s inline %s\n", fileMask, file.TreePath); err != nil {
228+
return "", nil, err
229+
}
230+
size := file.FileSize
231+
if size == 0 {
232+
size, err = getReadSeekerSize(file.ContentReader)
233+
if err != nil {
234+
return "", nil, err
235+
}
236+
}
237+
if _, err := fmt.Fprintf(f, "data %d\n", size+1); err != nil {
238+
return "", nil, err
239+
}
240+
if _, err := io.Copy(f, file.ContentReader); err != nil {
241+
return "", nil, err
242+
}
243+
if _, err := fmt.Fprintln(f); err != nil {
244+
return "", nil, err
245+
}
246+
case "delete":
247+
if file.FromTreePath == "" {
248+
return "", nil, fmt.Errorf("delete operation requires FromTreePath")
249+
}
250+
if _, err := fmt.Fprintf(f, "D %s\n", file.FromTreePath); err != nil {
251+
return "", nil, err
252+
}
253+
default:
254+
return "", nil, fmt.Errorf("unknown operation: %s", file.Operation)
255+
}
256+
}
257+
258+
return f.Name(), cancel, nil
259+
}

0 commit comments

Comments
 (0)