389 lines
9.5 KiB
Go
389 lines
9.5 KiB
Go
package reposync
|
|
|
|
import (
|
|
"bytes"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Config struct {
|
|
SourceType string
|
|
SourceURL string
|
|
TargetPath string
|
|
Branch string
|
|
Path string
|
|
SingleFile bool
|
|
Proxy string
|
|
ProxyURL string
|
|
AuthToken string
|
|
HttpProxy string
|
|
}
|
|
|
|
func Run(args []string) {
|
|
fs := flag.NewFlagSet("reposync", flag.ExitOnError)
|
|
var cfg Config
|
|
fs.StringVar(&cfg.SourceType, "source-type", "git", "Source type: git or url")
|
|
fs.StringVar(&cfg.SourceURL, "source-url", "", "Source url")
|
|
fs.StringVar(&cfg.TargetPath, "target-path", "", "Target path")
|
|
fs.StringVar(&cfg.Branch, "branch", "", "Branch")
|
|
fs.StringVar(&cfg.Path, "path", "", "Path for sparse checkout")
|
|
fs.BoolVar(&cfg.SingleFile, "single-file", false, "Single file mode")
|
|
fs.StringVar(&cfg.Proxy, "proxy", "none", "Proxy type")
|
|
fs.StringVar(&cfg.ProxyURL, "proxy-url", "", "Custom proxy url")
|
|
fs.StringVar(&cfg.AuthToken, "auth-token", "", "Auth token")
|
|
fs.StringVar(&cfg.HttpProxy, "http-proxy", "", "Http proxy")
|
|
|
|
fs.Parse(args)
|
|
|
|
if cfg.SourceURL == "" || cfg.TargetPath == "" {
|
|
fmt.Println("错误: 缺少 --source-url 或 --target-path 参数")
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Printf("参数: %s\n", strings.Join(args, " "))
|
|
|
|
if cfg.SourceType == "git" {
|
|
syncGit(cfg)
|
|
} else {
|
|
syncURL(cfg)
|
|
}
|
|
}
|
|
|
|
func syncGit(cfg Config) {
|
|
env := os.Environ()
|
|
|
|
if isRawFileURL(cfg.SourceURL) {
|
|
fmt.Println("检测到 raw 文件 URL,自动切换到 URL 下载模式")
|
|
syncURL(cfg)
|
|
return
|
|
}
|
|
|
|
if cfg.HttpProxy != "" {
|
|
env = append(env, "http_proxy="+cfg.HttpProxy, "https_proxy="+cfg.HttpProxy)
|
|
}
|
|
|
|
repoURL := buildProxyURL(cfg.SourceURL, cfg.Proxy, cfg.ProxyURL)
|
|
if cfg.AuthToken != "" && strings.HasPrefix(repoURL, "https://") {
|
|
repoURL = strings.Replace(repoURL, "https://", "https://"+cfg.AuthToken+"@", 1)
|
|
}
|
|
|
|
dest := cfg.TargetPath
|
|
|
|
if cfg.Path != "" && cfg.SingleFile {
|
|
syncGitFile(cfg, repoURL, env)
|
|
return
|
|
}
|
|
|
|
gitDir := filepath.Join(dest, ".git")
|
|
if isDir(dest) && !pathExists(gitDir) {
|
|
repoName := getRepoName(cfg.SourceURL)
|
|
dest = filepath.Join(dest, repoName)
|
|
fmt.Printf("目标路径自动追加仓库名: %s\n", dest)
|
|
gitDir = filepath.Join(dest, ".git")
|
|
}
|
|
|
|
if pathExists(gitDir) {
|
|
fmt.Println("检测到已存在仓库,执行 git pull")
|
|
if cfg.Branch != "" {
|
|
runCmd([]string{"git", "checkout", cfg.Branch}, dest, env)
|
|
}
|
|
runCmd([]string{"git", "pull"}, dest, env)
|
|
} else {
|
|
fmt.Println("执行 git clone")
|
|
parentDir := filepath.Dir(dest)
|
|
if parentDir != "" {
|
|
os.MkdirAll(parentDir, 0755)
|
|
}
|
|
|
|
if pathExists(dest) && !isDirEmpty(dest) {
|
|
fmt.Printf("错误: 目标目录 '%s' 已存在且不为空,无法执行 git clone\n", dest)
|
|
fmt.Println("提示: 请清空目标目录或指定一个新目录")
|
|
os.Exit(1)
|
|
}
|
|
|
|
cloneCmd := []string{"git", "clone", "--depth", "1"}
|
|
if cfg.Branch != "" {
|
|
cloneCmd = append(cloneCmd, "-b", cfg.Branch)
|
|
}
|
|
|
|
if cfg.Path != "" {
|
|
cloneCmd = append(cloneCmd, "--filter=blob:none", "--no-checkout", repoURL, dest)
|
|
runCmd(cloneCmd, "", env)
|
|
runCmd([]string{"git", "sparse-checkout", "init", "--cone"}, dest, env)
|
|
runCmd([]string{"git", "sparse-checkout", "set", cfg.Path}, dest, env)
|
|
runCmd([]string{"git", "checkout"}, dest, env)
|
|
} else {
|
|
cloneCmd = append(cloneCmd, repoURL, dest)
|
|
runCmd(cloneCmd, "", env)
|
|
}
|
|
}
|
|
fmt.Println("同步完成")
|
|
}
|
|
|
|
func syncURL(cfg Config) {
|
|
downloadURL := buildProxyURL(cfg.SourceURL, cfg.Proxy, cfg.ProxyURL)
|
|
fmt.Printf("下载地址: %s\n", downloadURL)
|
|
dest := cfg.TargetPath
|
|
|
|
if isDir(dest) || strings.HasSuffix(dest, string(os.PathSeparator)) || strings.HasSuffix(dest, "/") {
|
|
urlPath := strings.Split(cfg.SourceURL, "?")[0]
|
|
filename := filepath.Base(urlPath)
|
|
if filename == "" {
|
|
filename = "downloaded_file"
|
|
}
|
|
dest = filepath.Join(dest, filename)
|
|
fmt.Printf("目标文件: %s\n", dest)
|
|
}
|
|
|
|
downloadFile(downloadURL, dest, cfg.AuthToken)
|
|
}
|
|
|
|
func syncGitFile(cfg Config, repoURL string, env []string) {
|
|
sourceURL := cfg.SourceURL
|
|
filePath := cfg.Path
|
|
dest := cfg.TargetPath
|
|
|
|
if isDir(dest) || strings.HasSuffix(dest, string(os.PathSeparator)) || strings.HasSuffix(dest, "/") {
|
|
filename := filepath.Base(filePath)
|
|
dest = filepath.Join(dest, filename)
|
|
fmt.Printf("检测到目标路径为目录 '%s',自动修正为: '%s'\n", cfg.TargetPath, dest)
|
|
}
|
|
|
|
branch := cfg.Branch
|
|
if branch == "" {
|
|
branch = getRemoteDefaultBranch(repoURL, env)
|
|
}
|
|
|
|
cleanURL := strings.TrimSuffix(cfg.SourceURL, ".git")
|
|
rawURL := ""
|
|
|
|
if strings.Contains(sourceURL, "github.com") {
|
|
base := strings.Replace(strings.TrimSuffix(cfg.SourceURL, ".git"), "github.com", "raw.githubusercontent.com", 1)
|
|
rawURL = fmt.Sprintf("%s/%s/%s", base, branch, filePath)
|
|
} else if strings.Contains(sourceURL, "gitlab.com") {
|
|
rawURL = fmt.Sprintf("%s/-/raw/%s/%s", cleanURL, branch, filePath)
|
|
} else if strings.Contains(sourceURL, "gitee.com") {
|
|
rawURL = fmt.Sprintf("%s/raw/%s/%s", cleanURL, branch, filePath)
|
|
} else {
|
|
rawURL = fmt.Sprintf("%s/raw/%s/%s", cleanURL, branch, filePath)
|
|
}
|
|
|
|
rawURL = buildProxyURL(rawURL, cfg.Proxy, cfg.ProxyURL)
|
|
downloadFile(rawURL, dest, cfg.AuthToken)
|
|
}
|
|
|
|
func getRemoteDefaultBranch(repoURL string, env []string) string {
|
|
fmt.Printf("正在检测远程仓库默认分支: %s\n", repoURL)
|
|
cmd := exec.Command("git", "ls-remote", "--symref", repoURL, "HEAD")
|
|
cmd.Env = env
|
|
out, err := cmd.Output()
|
|
if err == nil {
|
|
lines := strings.Split(string(out), "\n")
|
|
for _, line := range lines {
|
|
parts := strings.Fields(line)
|
|
if len(parts) >= 2 && parts[0] == "ref:" && strings.Contains(parts[1], "refs/heads/") {
|
|
branch := strings.TrimPrefix(parts[1], "refs/heads/")
|
|
fmt.Printf("检测到默认分支: %s\n", branch)
|
|
return branch
|
|
}
|
|
}
|
|
}
|
|
fmt.Println("无法检测到默认分支,回退使用 'main'")
|
|
return "main"
|
|
}
|
|
|
|
func buildProxyURL(url string, proxyType string, proxyURL string) string {
|
|
if proxyType == "" || proxyType == "none" {
|
|
return url
|
|
}
|
|
base := ""
|
|
if proxyType == "ghproxy" {
|
|
base = "https://gh-proxy.com/"
|
|
} else if proxyType == "mirror" {
|
|
base = "https://mirror.ghproxy.com/"
|
|
} else if proxyType == "custom" && proxyURL != "" {
|
|
base = strings.TrimSuffix(proxyURL, "/") + "/"
|
|
}
|
|
|
|
if base != "" && strings.HasPrefix(url, "http") {
|
|
return base + url
|
|
}
|
|
return url
|
|
}
|
|
|
|
func downloadFile(url, dest, authToken string) {
|
|
fmt.Printf("下载地址: %s\n", url)
|
|
fmt.Printf("目标路径: %s\n", dest)
|
|
|
|
parentDir := filepath.Dir(dest)
|
|
if parentDir != "" {
|
|
os.MkdirAll(parentDir, 0755)
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
fmt.Printf("下载准备失败: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if authToken != "" {
|
|
req.Header.Set("Authorization", "token "+authToken)
|
|
}
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; reposync)")
|
|
|
|
client := &http.Client{Timeout: 300 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
fmt.Printf("下载请求失败: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
fmt.Printf("下载失败, HTTP 状态码: %d\n", resp.StatusCode)
|
|
os.Exit(1)
|
|
}
|
|
|
|
out, err := os.Create(dest)
|
|
if err != nil {
|
|
fmt.Printf("创建文件失败: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer out.Close()
|
|
|
|
n, err := io.Copy(out, resp.Body)
|
|
if err != nil {
|
|
fmt.Printf("写入数据失败: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Printf("文件大小: %d 字节\n", n)
|
|
fmt.Println("下载完成")
|
|
}
|
|
|
|
func isRawFileURL(url string) bool {
|
|
rawPatterns := []string{
|
|
"raw.githubusercontent.com",
|
|
"/raw/",
|
|
"/-/raw/",
|
|
"/blob/",
|
|
}
|
|
for _, p := range rawPatterns {
|
|
if strings.Contains(url, p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func getRepoName(url string) string {
|
|
u := strings.TrimSuffix(url, "/")
|
|
u = strings.TrimSuffix(u, ".git")
|
|
return filepath.Base(u)
|
|
}
|
|
|
|
var ansiRegex = regexp.MustCompile("\x1b\\[[0-9;]*[a-zA-Z]")
|
|
|
|
type cleanWriter struct {
|
|
out io.Writer
|
|
buf []byte
|
|
}
|
|
|
|
func (c *cleanWriter) Write(p []byte) (n int, err error) {
|
|
c.buf = append(c.buf, p...)
|
|
|
|
for {
|
|
idx := bytes.IndexAny(c.buf, "\r\n")
|
|
if idx == -1 {
|
|
break
|
|
}
|
|
|
|
if c.buf[idx] == '\r' && idx == len(c.buf)-1 {
|
|
// Ends with \r across a chunk, wait for next.
|
|
break
|
|
}
|
|
|
|
char := c.buf[idx]
|
|
line := string(c.buf[:idx])
|
|
c.buf = c.buf[idx+1:]
|
|
|
|
if char == '\r' && len(c.buf) > 0 && c.buf[0] == '\n' {
|
|
c.buf = c.buf[1:]
|
|
char = '\n'
|
|
}
|
|
|
|
s := ansiRegex.ReplaceAllString(line, "")
|
|
|
|
if char == '\r' {
|
|
continue // filter out terminal progress overwrites
|
|
}
|
|
|
|
if s != "" {
|
|
c.out.Write([]byte(s + "\n"))
|
|
}
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func (c *cleanWriter) Flush() {
|
|
if len(c.buf) > 0 {
|
|
s := string(c.buf)
|
|
if strings.HasSuffix(s, "\r") {
|
|
s = s[:len(s)-1]
|
|
}
|
|
s = ansiRegex.ReplaceAllString(s, "")
|
|
if s != "" {
|
|
c.out.Write([]byte(s + "\n"))
|
|
}
|
|
c.buf = nil
|
|
}
|
|
}
|
|
|
|
func runCmd(args []string, dir string, env []string) {
|
|
fmt.Printf(">> %s\n", strings.Join(args, " "))
|
|
cmd := exec.Command(args[0], args[1:]...)
|
|
cmd.Dir = dir
|
|
cmd.Env = env
|
|
|
|
cw := &cleanWriter{out: os.Stdout}
|
|
cmd.Stdout = cw
|
|
cmd.Stderr = cw
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
cw.Flush()
|
|
fmt.Printf("命令执行失败: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
cw.Flush()
|
|
}
|
|
|
|
func isDir(path string) bool {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return info.IsDir()
|
|
}
|
|
|
|
func pathExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil || !os.IsNotExist(err)
|
|
}
|
|
|
|
func isDirEmpty(path string) bool {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer f.Close()
|
|
_, err = f.Readdirnames(1)
|
|
return err == io.EOF
|
|
}
|