From 07f04af841d74008dac5184db7ef27f903eba8e4 Mon Sep 17 00:00:00 2001 From: engigu Date: Mon, 16 Mar 2026 10:50:07 +0800 Subject: [PATCH] feat: add repo sync white-path #56 --- cmd/reposync/reposync.go | 115 +++++++++++++++++++- internal/models/task.go | 3 +- internal/services/tasks/executor_service.go | 3 + internal/utils/fs.go | 77 +++++++++++++ web/src/api/index.ts | 1 + web/src/views/tasks/RepoDialog.vue | 48 ++++++++ 6 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 internal/utils/fs.go diff --git a/cmd/reposync/reposync.go b/cmd/reposync/reposync.go index 9617642..c77c744 100644 --- a/cmd/reposync/reposync.go +++ b/cmd/reposync/reposync.go @@ -12,6 +12,8 @@ import ( "regexp" "strings" "time" + + "github.com/engigu/baihu-panel/internal/utils" ) type Config struct { @@ -23,8 +25,9 @@ type Config struct { SingleFile bool Proxy string ProxyURL string - AuthToken string - HttpProxy string + AuthToken string + HttpProxy string + WhitelistPaths string // Comma separated paths to preserve (whitelist) } func Run(args []string) { @@ -40,6 +43,7 @@ func Run(args []string) { 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.StringVar(&cfg.WhitelistPaths, "whitelist-paths", "", "Comma separated paths to preserve (whitelist)") fs.Parse(args) @@ -90,6 +94,9 @@ func syncGit(cfg Config) { gitDir = filepath.Join(dest, ".git") } + restore := preserve(dest, cfg.WhitelistPaths) + defer restore() + if pathExists(gitDir) { fmt.Println("检测到已存在仓库,执行 git pull") if cfg.Branch != "" { @@ -104,10 +111,16 @@ func syncGit(cfg Config) { } if pathExists(dest) && !isDirEmpty(dest) { - fmt.Printf("错误: 目标目录 '%s' 已存在且不为空,无法执行 git clone\n", dest) + // If we still have files after preservation, warn but maybe continue if it's just leftovers that git can handle? + // Actually git clone requires an empty dir. + fmt.Printf("警告: 目标目录 '%s' 不为空,尝试清理非保护文件...\n", dest) + // Optional: delete everything else? User might not want that. + // For now, keep the error but it's less likely to occur if preservation moved things out. fmt.Println("提示: 请清空目标目录或指定一个新目录") os.Exit(1) } + // If dest exists but is empty now, git clone might still complain if the directory itself exists? + // No, git clone works if dir is empty. cloneCmd := []string{"git", "clone", "--depth", "1"} if cfg.Branch != "" { @@ -143,6 +156,9 @@ func syncURL(cfg Config) { fmt.Printf("目标文件: %s\n", dest) } + restore := preserve(cfg.TargetPath, cfg.WhitelistPaths) + defer restore() + downloadFile(downloadURL, dest, cfg.AuthToken) } @@ -386,3 +402,96 @@ func isDirEmpty(path string) bool { _, err = f.Readdirnames(1) return err == io.EOF } + +// preserve moves specified paths to a temporary location and returns a function to restore them +func preserve(baseDir string, paths string) func() { + if paths == "" || !pathExists(baseDir) { + return func() {} + } + + preservedList := strings.Split(paths, ",") + // 优化:将临时目录创建在 baseDir 同一级或内部,确保在同一个文件系统,使得 Rename 是 O(1) 瞬时完成的 + tmpParent, err := os.MkdirTemp(baseDir, ".baihu_sync_preserve_*") + if err != nil { + fmt.Printf("警告: 无法在目标目录创建临时目录用于保留文件: %v\n", err) + return func() {} + } + + type preservedItem struct { + relPath string + tmpPath string + } + var items []preservedItem + processed := make(map[string]bool) + + for _, p := range preservedList { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + // Support glob matching + pattern := filepath.Join(baseDir, p) + matches, err := filepath.Glob(pattern) + if err != nil { + fmt.Printf("警告: 路径模式无效 %s: %v\n", p, err) + continue + } + + // If literal path exists but Glob didn't find it (common for direct dir reference), add it manually + if len(matches) == 0 && pathExists(pattern) { + matches = []string{pattern} + } + + for _, fullPath := range matches { + relPath, err := filepath.Rel(baseDir, fullPath) + // 同时要排除掉临时目录本身以及上级路径 + if err != nil || strings.HasPrefix(relPath, "..") || relPath == "." || strings.HasPrefix(relPath, ".baihu_sync_preserve") { + continue + } + + if processed[relPath] { + continue + } + processed[relPath] = true + + tmpPath := filepath.Join(tmpParent, relPath) + os.MkdirAll(filepath.Dir(tmpPath), 0755) + + fmt.Printf("正在保护路径: %s\n", relPath) + if err := os.Rename(fullPath, tmpPath); err == nil { + items = append(items, preservedItem{relPath: relPath, tmpPath: tmpPath}) + } else { + // Rename might fail across filesystems, try copy + if err := utils.CopyPath(fullPath, tmpPath); err == nil { + os.RemoveAll(fullPath) + items = append(items, preservedItem{relPath: relPath, tmpPath: tmpPath}) + } else { + fmt.Printf("警告: 无法保护路径 %s: %v\n", relPath, err) + } + } + } + } + + return func() { + // Restore in reverse order to handle nested structures correctly if they were picked up separately + for i := len(items) - 1; i >= 0; i-- { + item := items[i] + destPath := filepath.Join(baseDir, item.relPath) + os.MkdirAll(filepath.Dir(destPath), 0755) + + if pathExists(destPath) { + fmt.Printf("目标已存在,覆盖恢复保护路径: %s\n", item.relPath) + os.RemoveAll(destPath) + } else { + fmt.Printf("正在恢复保护路径: %s\n", item.relPath) + } + + if err := os.Rename(item.tmpPath, destPath); err != nil { + // Fallback to copy + utils.CopyPath(item.tmpPath, destPath) + } + } + os.RemoveAll(tmpParent) + } +} diff --git a/internal/models/task.go b/internal/models/task.go index 6ab511d..ea99fd1 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -22,7 +22,8 @@ type RepoConfig struct { SingleFile bool `json:"single_file"` // 单文件模式(直接下载文件而非 sparse-checkout) Proxy string `json:"proxy"` // 代理类型: none, ghproxy, mirror, custom ProxyURL string `json:"proxy_url"` // 自定义代理地址 - AuthToken string `json:"auth_token"` // 认证 Token + AuthToken string `json:"auth_token"` // 认证 Token + WhitelistPaths string `json:"whitelist_paths"` // 同步时保留的路径(白名单路径),逗号分隔 } // TaskConfig 任务配置 RepoConfig+TaskConfig=task.config diff --git a/internal/services/tasks/executor_service.go b/internal/services/tasks/executor_service.go index d6a98b9..f4cab06 100644 --- a/internal/services/tasks/executor_service.go +++ b/internal/services/tasks/executor_service.go @@ -946,6 +946,9 @@ func (es *ExecutorService) BuildRepoCommand(task *models.Task) (string, string) if config.AuthToken != "" { args = append(args, "--auth-token", config.AuthToken) } + if config.WhitelistPaths != "" { + args = append(args, "--whitelist-paths", config.WhitelistPaths) + } return exePath + " " + strings.Join(args, " "), filepath.Dir(exePath) } diff --git a/internal/utils/fs.go b/internal/utils/fs.go new file mode 100644 index 0000000..87012f6 --- /dev/null +++ b/internal/utils/fs.go @@ -0,0 +1,77 @@ +package utils + +import ( + "io" + "os" + "path/filepath" +) + +// CopyPath copies a file or directory from src to dest +func CopyPath(src, dest string) error { + info, err := os.Stat(src) + if err != nil { + return err + } + + if info.IsDir() { + return copyDir(src, dest) + } + return CopyFile(src, dest) +} + +// CopyFile copies a single file from src to dest +func CopyFile(src, dest string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return err + } + + destFile, err := os.Create(dest) + if err != nil { + return err + } + defer destFile.Close() + + if _, err := io.Copy(destFile, srcFile); err != nil { + return err + } + + info, err := os.Stat(src) + if err == nil { + os.Chmod(dest, info.Mode()) + } + + return nil +} + +func copyDir(src, dest string) error { + info, err := os.Stat(src) + if err != nil { + return err + } + + if err := os.MkdirAll(dest, info.Mode()); err != nil { + return err + } + + entries, err := os.ReadDir(src) + if err != nil { + return err + } + + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + destPath := filepath.Join(dest, entry.Name()) + + if err := CopyPath(srcPath, destPath); err != nil { + return err + } + } + + return nil +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 2f930ca..e0278be 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -346,6 +346,7 @@ export interface RepoConfig { proxy: string proxy_url: string auth_token: string + whitelist_paths?: string concurrency?: number } diff --git a/web/src/views/tasks/RepoDialog.vue b/web/src/views/tasks/RepoDialog.vue index 14a12b5..f7d39f3 100644 --- a/web/src/views/tasks/RepoDialog.vue +++ b/web/src/views/tasks/RepoDialog.vue @@ -54,6 +54,7 @@ const repoConfig = ref({ single_file: false, proxy_url: '', auth_token: '', + whitelist_paths: '', concurrency: 1, proxy: '' }) @@ -62,6 +63,7 @@ const cleanKeep = ref(30) const allAgents = ref([]) const selectedAgentId = ref('local') const tagInput = ref('') +const whitelistInput = ref('') function addTag() { const val = tagInput.value.trim() @@ -79,6 +81,22 @@ function removeTag(tagToRemove: string) { form.value.tags = currentTags.filter(t => t !== tagToRemove).join(',') } +function addWhitelistPath() { + const val = whitelistInput.value.trim() + if (!val) return + const current = repoConfig.value.whitelist_paths ? repoConfig.value.whitelist_paths.split(',').filter(Boolean) : [] + if (!current.includes(val)) { + current.push(val) + repoConfig.value.whitelist_paths = current.join(',') + } + whitelistInput.value = '' +} + +function removeWhitelistPath(path: string) { + const current = repoConfig.value.whitelist_paths ? repoConfig.value.whitelist_paths.split(',').filter(Boolean) : [] + repoConfig.value.whitelist_paths = current.filter(p => p !== path).join(',') +} + const concurrencyEnabled = computed({ get: () => repoConfig.value.concurrency === 1, set: (val: boolean) => { @@ -137,6 +155,7 @@ watch(() => props.open, async (val) => { proxy: 'none', proxy_url: '', auth_token: '', + whitelist_paths: '', concurrency: 1 } const configStr = props.task?.config @@ -300,6 +319,35 @@ async function save() { + + +
+ +
+
+
+ + +
+
+
+ + {{ path }} + + +
+

+ 同步时将保留匹配上述路径的内容(支持 * 通配符)。匹配项在同步前会被暂存,并在同步完成后自动回填还原。 +

+
+