git feature

This commit is contained in:
2026-06-30 14:46:19 +07:00
parent 251c1b7573
commit 11e5a76977
23 changed files with 1680 additions and 28 deletions

View File

@@ -0,0 +1,260 @@
package handlers
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
const githubBlobMaxBytes = 100 * 1024 * 1024
func collectDirFiles(rootDir string) ([]gitPathFile, []string) {
cleanRoot := filepath.Clean(rootDir)
byPath := map[string]gitPathFile{}
var warnings []string
_ = filepath.Walk(cleanRoot, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
warnings = append(warnings, path+": "+walkErr.Error())
return nil
}
if info.IsDir() {
if shouldSkipBundledPath(filepath.Base(path)) && path != cleanRoot {
return filepath.SkipDir
}
return nil
}
rel, err := filepath.Rel(cleanRoot, path)
if err != nil {
warnings = append(warnings, path+": "+err.Error())
return nil
}
rel = filepath.ToSlash(rel)
if rel == "" || strings.HasPrefix(rel, "..") || shouldSkipBundledPath(rel) {
return nil
}
if info.Size() > githubBlobMaxBytes {
warnings = append(warnings, rel+": file quá lớn (>100MB), bỏ qua")
return nil
}
data, err := os.ReadFile(path)
if err != nil {
warnings = append(warnings, rel+": "+err.Error())
return nil
}
byPath[rel] = gitPathFile{path: rel, content: data}
return nil
})
out := make([]gitPathFile, 0, len(byPath))
for _, f := range byPath {
out = append(out, f)
}
return out, warnings
}
type gitPathFile struct {
path string
content []byte
}
func githubCreateBlob(token, owner, repo string, content []byte) (string, error) {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/blobs", owner, repo)
body := map[string]string{
"content": base64.StdEncoding.EncodeToString(content),
"encoding": "base64",
}
var resp struct {
SHA string `json:"sha"`
}
if err := githubJSON(token, http.MethodPost, apiURL, body, &resp); err != nil {
return "", err
}
if resp.SHA == "" {
return "", errors.New("GitHub không trả blob sha")
}
return resp.SHA, nil
}
func githubCreateTree(token, owner, repo, baseTreeSHA string, entries []map[string]string) (string, error) {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/trees", owner, repo)
body := map[string]any{"tree": entries}
if baseTreeSHA != "" {
body["base_tree"] = baseTreeSHA
}
var resp struct {
SHA string `json:"sha"`
}
if err := githubJSON(token, http.MethodPost, apiURL, body, &resp); err != nil {
return "", err
}
if resp.SHA == "" {
return "", errors.New("GitHub không trả tree sha")
}
return resp.SHA, nil
}
func githubCreateCommit(token, owner, repo, treeSHA, parentSHA, message string) (string, error) {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/commits", owner, repo)
body := map[string]any{
"message": message,
"tree": treeSHA,
}
if parentSHA != "" {
body["parents"] = []string{parentSHA}
}
var resp struct {
SHA string `json:"sha"`
}
if err := githubJSON(token, http.MethodPost, apiURL, body, &resp); err != nil {
return "", err
}
if resp.SHA == "" {
return "", errors.New("GitHub không trả commit sha")
}
return resp.SHA, nil
}
func githubUpdateBranchRef(token, owner, repo, branch, commitSHA string) error {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/refs/heads/%s", owner, repo, branch)
body := map[string]any{"sha": commitSHA, "force": false}
err := githubJSON(token, http.MethodPatch, apiURL, body, nil)
if err == nil {
return nil
}
createURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/refs", owner, repo)
createBody := map[string]string{
"ref": "refs/heads/" + branch,
"sha": commitSHA,
}
return githubJSON(token, http.MethodPost, createURL, createBody, nil)
}
func githubBranchTip(token, owner, repo, branch string) (commitSHA, treeSHA string, err error) {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/ref/heads/%s", owner, repo, branch)
var refResp struct {
Object struct {
SHA string `json:"sha"`
} `json:"object"`
}
if err := githubJSON(token, http.MethodGet, apiURL, nil, &refResp); err != nil {
return "", "", err
}
commitSHA = refResp.Object.SHA
if commitSHA == "" {
return "", "", errors.New("không lấy được commit hiện tại")
}
commitURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/commits/%s", owner, repo, commitSHA)
var commitResp struct {
Tree struct {
SHA string `json:"sha"`
} `json:"tree"`
}
if err := githubJSON(token, http.MethodGet, commitURL, nil, &commitResp); err != nil {
return "", "", err
}
return commitSHA, commitResp.Tree.SHA, nil
}
func githubJSON(token, method, apiURL string, body any, out any) error {
var reader io.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(payload)
}
req, err := http.NewRequest(method, apiURL, reader)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
var errBody struct {
Message string `json:"message"`
}
_ = json.Unmarshal(raw, &errBody)
if errBody.Message != "" {
return errors.New(errBody.Message)
}
return fmt.Errorf("GitHub API lỗi (%d)", resp.StatusCode)
}
if out != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return err
}
}
return nil
}
// githubPushFiles — đẩy danh sách file lên repo; file lỗi được bỏ qua.
func githubPushFiles(token, owner, repo, branch string, files []gitPathFile, message string) (string, []string, error) {
warnings := []string{}
if len(files) == 0 {
return "", warnings, errors.New("không có file để đẩy")
}
parentSHA, baseTreeSHA, tipErr := githubBranchTip(token, owner, repo, branch)
if tipErr != nil {
parentSHA = ""
baseTreeSHA = ""
}
entries := make([]map[string]string, 0, len(files))
for _, f := range files {
blobSHA, err := githubCreateBlob(token, owner, repo, f.content)
if err != nil {
warnings = append(warnings, f.path+": "+err.Error()+" (bỏ qua)")
continue
}
entries = append(entries, map[string]string{
"path": f.path,
"mode": "100644",
"type": "blob",
"sha": blobSHA,
})
}
if len(entries) == 0 {
return "", warnings, errors.New("không đẩy được file nào lên GitHub")
}
treeSHA, err := githubCreateTree(token, owner, repo, baseTreeSHA, entries)
if err != nil {
return "", warnings, err
}
commitSHA, err := githubCreateCommit(token, owner, repo, treeSHA, parentSHA, message)
if err != nil {
return "", warnings, err
}
if err := githubUpdateBranchRef(token, owner, repo, branch, commitSHA); err != nil {
return "", warnings, err
}
return fmt.Sprintf("https://github.com/%s/%s/tree/%s", owner, repo, branch), warnings, nil
}
// githubPushDirectory — đẩy file lên repo; file lỗi được bỏ qua, không chặn cả lần push.
func githubPushDirectory(token, owner, repo, branch, localRoot, message string) (string, []string, error) {
files, collectWarn := collectDirFiles(localRoot)
warnings := append([]string{}, collectWarn...)
url, pushWarn, err := githubPushFiles(token, owner, repo, branch, files, message)
return url, append(warnings, pushWarn...), err
}