feat: add repo sync white-path #56
This commit is contained in:
+112
-3
@@ -12,6 +12,8 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/engigu/baihu-panel/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -23,8 +25,9 @@ type Config struct {
|
|||||||
SingleFile bool
|
SingleFile bool
|
||||||
Proxy string
|
Proxy string
|
||||||
ProxyURL string
|
ProxyURL string
|
||||||
AuthToken string
|
AuthToken string
|
||||||
HttpProxy string
|
HttpProxy string
|
||||||
|
WhitelistPaths string // Comma separated paths to preserve (whitelist)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(args []string) {
|
func Run(args []string) {
|
||||||
@@ -40,6 +43,7 @@ func Run(args []string) {
|
|||||||
fs.StringVar(&cfg.ProxyURL, "proxy-url", "", "Custom proxy url")
|
fs.StringVar(&cfg.ProxyURL, "proxy-url", "", "Custom proxy url")
|
||||||
fs.StringVar(&cfg.AuthToken, "auth-token", "", "Auth token")
|
fs.StringVar(&cfg.AuthToken, "auth-token", "", "Auth token")
|
||||||
fs.StringVar(&cfg.HttpProxy, "http-proxy", "", "Http proxy")
|
fs.StringVar(&cfg.HttpProxy, "http-proxy", "", "Http proxy")
|
||||||
|
fs.StringVar(&cfg.WhitelistPaths, "whitelist-paths", "", "Comma separated paths to preserve (whitelist)")
|
||||||
|
|
||||||
fs.Parse(args)
|
fs.Parse(args)
|
||||||
|
|
||||||
@@ -90,6 +94,9 @@ func syncGit(cfg Config) {
|
|||||||
gitDir = filepath.Join(dest, ".git")
|
gitDir = filepath.Join(dest, ".git")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restore := preserve(dest, cfg.WhitelistPaths)
|
||||||
|
defer restore()
|
||||||
|
|
||||||
if pathExists(gitDir) {
|
if pathExists(gitDir) {
|
||||||
fmt.Println("检测到已存在仓库,执行 git pull")
|
fmt.Println("检测到已存在仓库,执行 git pull")
|
||||||
if cfg.Branch != "" {
|
if cfg.Branch != "" {
|
||||||
@@ -104,10 +111,16 @@ func syncGit(cfg Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if pathExists(dest) && !isDirEmpty(dest) {
|
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("提示: 请清空目标目录或指定一个新目录")
|
fmt.Println("提示: 请清空目标目录或指定一个新目录")
|
||||||
os.Exit(1)
|
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"}
|
cloneCmd := []string{"git", "clone", "--depth", "1"}
|
||||||
if cfg.Branch != "" {
|
if cfg.Branch != "" {
|
||||||
@@ -143,6 +156,9 @@ func syncURL(cfg Config) {
|
|||||||
fmt.Printf("目标文件: %s\n", dest)
|
fmt.Printf("目标文件: %s\n", dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restore := preserve(cfg.TargetPath, cfg.WhitelistPaths)
|
||||||
|
defer restore()
|
||||||
|
|
||||||
downloadFile(downloadURL, dest, cfg.AuthToken)
|
downloadFile(downloadURL, dest, cfg.AuthToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,3 +402,96 @@ func isDirEmpty(path string) bool {
|
|||||||
_, err = f.Readdirnames(1)
|
_, err = f.Readdirnames(1)
|
||||||
return err == io.EOF
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ type RepoConfig struct {
|
|||||||
SingleFile bool `json:"single_file"` // 单文件模式(直接下载文件而非 sparse-checkout)
|
SingleFile bool `json:"single_file"` // 单文件模式(直接下载文件而非 sparse-checkout)
|
||||||
Proxy string `json:"proxy"` // 代理类型: none, ghproxy, mirror, custom
|
Proxy string `json:"proxy"` // 代理类型: none, ghproxy, mirror, custom
|
||||||
ProxyURL string `json:"proxy_url"` // 自定义代理地址
|
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
|
// TaskConfig 任务配置 RepoConfig+TaskConfig=task.config
|
||||||
|
|||||||
@@ -946,6 +946,9 @@ func (es *ExecutorService) BuildRepoCommand(task *models.Task) (string, string)
|
|||||||
if config.AuthToken != "" {
|
if config.AuthToken != "" {
|
||||||
args = append(args, "--auth-token", 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)
|
return exePath + " " + strings.Join(args, " "), filepath.Dir(exePath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -346,6 +346,7 @@ export interface RepoConfig {
|
|||||||
proxy: string
|
proxy: string
|
||||||
proxy_url: string
|
proxy_url: string
|
||||||
auth_token: string
|
auth_token: string
|
||||||
|
whitelist_paths?: string
|
||||||
concurrency?: number
|
concurrency?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const repoConfig = ref<RepoConfig>({
|
|||||||
single_file: false,
|
single_file: false,
|
||||||
proxy_url: '',
|
proxy_url: '',
|
||||||
auth_token: '',
|
auth_token: '',
|
||||||
|
whitelist_paths: '',
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
proxy: ''
|
proxy: ''
|
||||||
})
|
})
|
||||||
@@ -62,6 +63,7 @@ const cleanKeep = ref(30)
|
|||||||
const allAgents = ref<Agent[]>([])
|
const allAgents = ref<Agent[]>([])
|
||||||
const selectedAgentId = ref<string>('local')
|
const selectedAgentId = ref<string>('local')
|
||||||
const tagInput = ref('')
|
const tagInput = ref('')
|
||||||
|
const whitelistInput = ref('')
|
||||||
|
|
||||||
function addTag() {
|
function addTag() {
|
||||||
const val = tagInput.value.trim()
|
const val = tagInput.value.trim()
|
||||||
@@ -79,6 +81,22 @@ function removeTag(tagToRemove: string) {
|
|||||||
form.value.tags = currentTags.filter(t => t !== tagToRemove).join(',')
|
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({
|
const concurrencyEnabled = computed({
|
||||||
get: () => repoConfig.value.concurrency === 1,
|
get: () => repoConfig.value.concurrency === 1,
|
||||||
set: (val: boolean) => {
|
set: (val: boolean) => {
|
||||||
@@ -137,6 +155,7 @@ watch(() => props.open, async (val) => {
|
|||||||
proxy: 'none',
|
proxy: 'none',
|
||||||
proxy_url: '',
|
proxy_url: '',
|
||||||
auth_token: '',
|
auth_token: '',
|
||||||
|
whitelist_paths: '',
|
||||||
concurrency: 1
|
concurrency: 1
|
||||||
}
|
}
|
||||||
const configStr = props.task?.config
|
const configStr = props.task?.config
|
||||||
@@ -300,6 +319,35 @@ async function save() {
|
|||||||
<Input v-else v-model="repoConfig.target_path" placeholder="Agent 上的目标路径" class="h-9 bg-muted/30 border-muted-foreground/20" />
|
<Input v-else v-model="repoConfig.target_path" placeholder="Agent 上的目标路径" class="h-9 bg-muted/30 border-muted-foreground/20" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增:白名单路径 -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-4 items-start gap-3">
|
||||||
|
<Label class="sm:text-right text-xs text-muted-foreground uppercase tracking-wider pt-2.5">
|
||||||
|
白名单路径
|
||||||
|
</Label>
|
||||||
|
<div class="sm:col-span-3 space-y-2">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<Input v-model="whitelistInput" placeholder="输入路径或通配符按回车... (如 logs/ 或 *.db)" class="h-9 bg-muted/30 border-muted-foreground/20 pr-12 focus:bg-background" @keydown.enter.prevent="addWhitelistPath" />
|
||||||
|
<Button type="button" variant="ghost" size="sm" class="absolute right-1 top-1 h-7 px-2 text-xs hover:bg-primary/10 hover:text-primary transition-colors" @click="addWhitelistPath">
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1.5 pt-1 min-h-[1.5rem]" v-if="repoConfig.whitelist_paths">
|
||||||
|
<span v-for="path in repoConfig.whitelist_paths.split(',').filter(Boolean)" :key="path"
|
||||||
|
class="flex items-center gap-1.5 bg-blue-500/5 text-blue-500 px-2.5 py-1 rounded-md text-[11px] font-medium border border-blue-500/10 group transition-all hover:bg-blue-500/10">
|
||||||
|
{{ path }}
|
||||||
|
<button type="button" class="text-blue-500/40 hover:text-destructive transition-colors shrink-0" @click.prevent="removeWhitelistPath(path)">
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] text-muted-foreground mt-1 px-1 leading-relaxed">
|
||||||
|
同步时将保留匹配上述路径的内容(支持 * 通配符)。匹配项在同步前会被暂存,并在同步完成后自动回填还原。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="repoConfig.source_type === 'git'" class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
<div v-if="repoConfig.source_type === 'git'" class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||||
<Label class="sm:text-right text-xs text-muted-foreground uppercase tracking-wider">分支</Label>
|
<Label class="sm:text-right text-xs text-muted-foreground uppercase tracking-wider">分支</Label>
|
||||||
|
|||||||
Reference in New Issue
Block a user