diff --git a/pkg/commands/git_cmd_obj_runner.go b/pkg/commands/git_cmd_obj_runner.go index 8112b0f30..144ff58c3 100644 --- a/pkg/commands/git_cmd_obj_runner.go +++ b/pkg/commands/git_cmd_obj_runner.go @@ -46,7 +46,8 @@ func isRetryableError(output string, err error) bool { text += "\n" + err.Error() } return strings.Contains(text, "index.lock") || - strings.Contains(text, "cannot lock ref") + strings.Contains(text, "cannot lock ref") || + strings.Contains(text, "resource temporarily unavailable") } func (self *gitCmdObjRunner) Run(cmdObj *oscommands.CmdObj) error { diff --git a/pkg/commands/git_commands/remote.go b/pkg/commands/git_commands/remote.go index e5655c435..b9e3cce46 100644 --- a/pkg/commands/git_commands/remote.go +++ b/pkg/commands/git_commands/remote.go @@ -51,7 +51,7 @@ func (self *RemoteCommands) UpdateRemoteUrl(remoteName string, updatedUrl string } func (self *RemoteCommands) DeleteRemoteBranch(task gocui.Task, remoteName string, branchNames []string) error { - if remoteName == "git-svn" { + if self.IsSvnRepo() && remoteName == self.Svn.GetSvnRemoteName() { return fmt.Errorf("cannot delete git-svn remote branch via git push; use svn delete instead") } cmdArgs := NewGitCmd("push"). diff --git a/pkg/commands/git_commands/remote_loader.go b/pkg/commands/git_commands/remote_loader.go index af3c80dd4..6cf5fd136 100644 --- a/pkg/commands/git_commands/remote_loader.go +++ b/pkg/commands/git_commands/remote_loader.go @@ -56,23 +56,24 @@ func (self *RemoteLoader) GetRemotes() ([]*models.Remote, error) { } // SVN 仓库:注入 git-svn 虚拟 remote - // go-git 的 repo.Remotes() 不包含 git-svn,但 git for-each-ref 已经扫描到了 - // refs/remotes/git-svn/*,需要手动注入使其显示在 UI 中 + // go-git 的 repo.Remotes() 不包含 svn-remote,但 git for-each-ref 已经扫描到了 + // refs/remotes//*,需要手动注入使其显示在 UI 中 if self.gitCommon != nil && self.gitCommon.IsSvnRepo() { + svnRemoteName := self.gitCommon.Svn.GetSvnRemoteName() tagsPaths, _ := self.gitCommon.Svn.GetTagsRefsPaths() - svnBranches := remoteBranchesByRemoteName["git-svn"] + svnBranches := remoteBranchesByRemoteName[svnRemoteName] // 过滤掉属于 tags 的分支(应由 Tags 界面管理) var filteredBranches []*models.RemoteBranch for _, b := range svnBranches { - if !self.isTagRef(b.Name, tagsPaths) { + if !self.isTagRef(b.Name, tagsPaths, svnRemoteName) { filteredBranches = append(filteredBranches, b) } } if len(filteredBranches) > 0 || len(svnBranches) > 0 { remotes = append(remotes, &models.Remote{ - Name: "git-svn", + Name: svnRemoteName, Urls: []string{"(git-svn)"}, Branches: filteredBranches, }) @@ -190,9 +191,9 @@ func (self *RemoteLoader) getRemoteBranchesByRemoteName() (map[string][]*models. return remoteBranchesByRemoteName, nil } -func (self *RemoteLoader) isTagRef(refName string, tagsPaths []string) bool { +func (self *RemoteLoader) isTagRef(refName string, tagsPaths []string, svnRemoteName string) bool { for _, path := range tagsPaths { - fullRef := "refs/remotes/git-svn/" + refName + fullRef := "refs/remotes/" + svnRemoteName + "/" + refName if strings.HasPrefix(fullRef, path) { return true } diff --git a/pkg/commands/git_commands/svn_commands.go b/pkg/commands/git_commands/svn_commands.go index 0cd9f2c07..14e1f7f0a 100644 --- a/pkg/commands/git_commands/svn_commands.go +++ b/pkg/commands/git_commands/svn_commands.go @@ -44,6 +44,23 @@ func NewSvnCommands(gitCommon *GitCommon, cmd oscommands.ICmdObjBuilder) *SvnCom } } +// GetSvnRemoteName 从 ref 影射中提取实际的 SVN 远程名。 +// 例如 RefsPath 为 “refs/remotes/svn/trunk” 时返回 “svn”。 +func (self *SvnCommands) GetSvnRemoteName() string { + mappings, err := self.GetSvnRefMappings() + if err != nil || len(mappings) == 0 { + return "svn" + } + + for _, m := range mappings { + parts := strings.SplitN(m.RefsPath, "/", 4) + if len(parts) >= 3 && parts[0] == "refs" && parts[1] == "remotes" { + return parts[2] + } + } + return "svn" +} + // GetSvnUrl 从git config获取SVN仓库URL // 返回值如 https://svn.example.com/repo // 结果缓存60秒 @@ -189,16 +206,19 @@ func (self *SvnCommands) GetSvnUpstream(branchName string) (string, string, erro return "", "", err } + remoteName := self.GetSvnRemoteName() + refsPrefix := "refs/remotes/" + remoteName + "/" + for _, m := range mappings { if relPath == m.SvnPath { - upstreamBranch := strings.TrimPrefix(m.RefsPath, "refs/remotes/git-svn/") - return "git-svn", upstreamBranch, nil + upstreamBranch := strings.TrimPrefix(m.RefsPath, refsPrefix) + return remoteName, upstreamBranch, nil } if strings.HasPrefix(relPath, m.SvnPath+"/") { remaining := strings.TrimPrefix(relPath, m.SvnPath) fullRef := m.RefsPath + remaining - upstreamBranch := strings.TrimPrefix(fullRef, "refs/remotes/git-svn/") - return "git-svn", upstreamBranch, nil + upstreamBranch := strings.TrimPrefix(fullRef, refsPrefix) + return remoteName, upstreamBranch, nil } } @@ -242,10 +262,11 @@ func (self *SvnCommands) DeleteServerBranch(task gocui.Task, branchPath string) return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() } -// DeleteLocalRef 仅删除本地远程跟踪引用(refs/remotes/git-svn/xxx) -// 不影响 SVN 服务器,安全操作 +// DeleteLocalRef 仅删除本地远程跟踪引用(refs/remotes//xxx) +// 不影响 SVN 服务器,安全操作。使用 git update-ref -d 而非 git branch -D -r, +// 因为后者只适用于 branch 格式的 ref 名,不适用于 tags 路径。 func (self *SvnCommands) DeleteLocalRef(refName string) error { - cmdArgs := NewGitCmd("branch").Arg("-D").Arg("-r").Arg(refName).ToArgv() + cmdArgs := NewGitCmd("update-ref").Arg("-d").Arg(refName).ToArgv() return self.cmd.New(cmdArgs).Run() } @@ -255,6 +276,53 @@ func (self *SvnCommands) Fetch() error { return self.cmd.New(cmdArgs).Run() } +// InvalidateStatusCache 清除 CheckBranchStatus 的缓存。 +// 应在 git svn fetch 之后调用,确保下次检测使用最新数据。 +func (self *SvnCommands) InvalidateStatusCache() { + self.statusCacheMutex.Lock() + defer self.statusCacheMutex.Unlock() + self.statusCache = make(map[string]map[string]models.SvnBranchStatus) + self.statusCacheExpiry = make(map[string]time.Time) +} + +// PruneStaleRefs 删除 SVN 服务器上已不存在的本地远程跟踪引用。 +// refType: “branches” 或 “tags” +// 返回已删除的 ref 相对路径列表(与 RemoteBranch.Name 格式一致)。 +func (self *SvnCommands) PruneStaleRefs(refType string) ([]string, error) { + statuses, err := self.CheckBranchStatus(nil, refType) + if err != nil { + return nil, err + } + var pruned []string + refsPrefix := "refs/remotes/" + self.GetSvnRemoteName() + "/" + for path, status := range statuses { + if status == models.SvnBranchStatusStale { + fullRef := refsPrefix + path + if err := self.DeleteLocalRef(fullRef); err != nil { + self.GitCommon.Log.Warnf("failed to prune stale ref %s: %v", fullRef, err) + continue + } + pruned = append(pruned, path) + } + } + return pruned, nil +} +// GetMissingRefs 返回 SVN 服务器上存在但本地未 fetch 的 ref 路径列表。 +// refType: “branches” 或 “tags” +func (self *SvnCommands) GetMissingRefs(refType string) ([]string, error) { + statuses, err := self.CheckBranchStatus(nil, refType) + if err != nil { + return nil, err + } + var missing []string + for path, status := range statuses { + if status == models.SvnBranchStatusMissing { + missing = append(missing, path) + } + } + return missing, nil +} + // CheckBranchStatus 检测本地 refs 和 SVN 服务器的差异 // refType: "branches" 或 "tags" // 返回值:map[refRelPath]models.SvnBranchStatus, refRelPath 为 ref 相对路 @@ -279,9 +347,10 @@ func (self *SvnCommands) CheckBranchStatus(task gocui.Task, refType string) (map } mappings, _ := self.GetSvnRefMappings() + refsPrefix := "refs/remotes/" + self.GetSvnRemoteName() + "/" // 1. 获取本地 refs(遍历该类型所有 mapping 的 RefsPath, - // 兼容refs 端影射到非标准路径的配置,如 tags = tags/*:refs/remotes/git-svn/releases/*) + // 兼容refs 端影射到非标准路径的配置,如 tags = tags/*:refs/remotes/svn/releases/*) localRefs := make(map[string]bool) for _, m := range mappings { if m.Type != refType { @@ -296,7 +365,35 @@ func (self *SvnCommands) CheckBranchStatus(task gocui.Task, refType string) (map for _, line := range strings.Split(strings.TrimSpace(output), "\n") { if line := strings.TrimSpace(line); line != "" { // Key 统一为 ref 相对路径 - localRefs[strings.TrimPrefix(line, "refs/remotes/git-svn/")] = true + localRefs[strings.TrimPrefix(line, refsPrefix)] = true + } + } + } + // 排除属于其他类型 mapping 的 ref,避免误判为 Stale。 + // 例如 branches 的 RefsPath 为 refs/remotes/svn, 会匹配到 trunk 和 tags/*。 + // 但它们分别属于 trunk 和 tags类型,不应出现在 branches 的 localRefs 中。 + // 仅当其他 mapping 的 RefsPath 落在当前 refType 某个 mapping 的 RefsPath 范围内时 + // 才需要排除——因为只有此时 for-each-ref 才可能返回属于其他类型的 ref。 + // 反之若当前 refType 的 RefsPath 更具体(如 tags 的 refs/remotes/svn/tags), + // for-each-ref 不会返回其他类型的 ref,无需排除。 + for _, other := range mappings { + if other.Type == refType { + continue + } + isSubPath := false + for _, m := range mappings { + if m.Type == refType && (other.RefsPath == m.RefsPath || strings.HasPrefix(other.RefsPath, m.RefsPath+"/")) { + isSubPath = true + break + } + } + if isSubPath { + continue + } + for ref := range localRefs { + fullRef := refsPrefix + ref + if fullRef == other.RefsPath || strings.HasPrefix(fullRef, other.RefsPath+"/") { + delete(localRefs, ref) } } } @@ -320,16 +417,24 @@ func (self *SvnCommands) CheckBranchStatus(task gocui.Task, refType string) (map if line := strings.TrimSpace(line); line != "" { name := strings.TrimSuffix(line, "/") // SVN 路径正向影射为 ref 相对路径,与 localRefs 的 key 格式统一 - svnBranches[strings.TrimPrefix(m.RefsPath, "refs/remotes/git-svn/")+"/"+name] = true + // m.RefsPath 去掉通配符后无尾斜杠(如 refs/remotes/svn), + // 需先补 “/” 再 TrimPrefix refsPrefix (如 refs/remotes/svn/), + // 否则 TrimPrefix 不匹配,key 变成完整 ref 路径而非相对路径。 + svnBranches[strings.TrimPrefix(m.RefsPath+"/", refsPrefix)+name] = true } } + } else { + self.GitCommon.Log.Warnf("svn list failed for %s: %v", svnUrl+"/"+m.SvnPath, listErr) } } - // svn list 全部失败(网络不通、认证失败等)时返回错误并中止, - // 避免把”全部 Stale”的误导性结果当作真实状态展示(错误不缓存,下次自动重时) + // Svn list failed for all path: svn may be unavailable, network or auth issue. + // Return empty result with nil error to avoid error dialog. + // Stale status keeps default (Unknown), does not affect core functionality. + // Error is not cached, next call will retry automatically. if svnListAttempted && !svnListOk { - return nil, fmt.Errorf("svn list failed for all %s paths (network or auth error?)", refType) + self.GitCommon.Log.Warnf("svn list failed for all %s paths, skipping stale detection", refType) + return map[string]models.SvnBranchStatus{}, nil } // 3. 对比差异 diff --git a/pkg/commands/git_commands/sync.go b/pkg/commands/git_commands/sync.go index f3f7777ad..9a953e566 100644 --- a/pkg/commands/git_commands/sync.go +++ b/pkg/commands/git_commands/sync.go @@ -147,9 +147,11 @@ func (self *SyncCommands) FastForward( } func (self *SyncCommands) FetchRemote(task gocui.Task, remoteName string) error { - if self.IsGitSvnRepo && remoteName == "git-svn" { + if self.IsGitSvnRepo && remoteName == self.Svn.GetSvnRemoteName() { cmdArgs := NewGitCmd("svn").Arg("fetch").ToArgv() - return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() + err := self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() + self.Svn.InvalidateStatusCache() + return err } cmdArgs := self.fetchCommandBuilder(false). Arg(remoteName). diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 1b225e3d4..4bbcd6e31 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -56,7 +56,7 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error } func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteBranch, resetRemoteBranchesSelection bool) error { - if len(remoteBranches) > 0 && remoteBranches[0].RemoteName == "git-svn" { + if len(remoteBranches) > 0 && self.c.Git().Sync.GitCommon.IsSvnRepo() && remoteBranches[0].RemoteName == self.c.Git().Svn.GetSvnRemoteName() { return errors.New("cannot use standard remote delete for git-svn remote branches; use SVN-specific deletion") } var title string diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index f7290a19e..319c18179 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1891,23 +1891,52 @@ func (self *RefreshHelper) savePullRequestsToCache(prs []*models.GithubPullReque func (self *RefreshHelper) checkSvnTagStatusAsync() { self.c.WithWaitingStatus(self.c.Tr.CheckingSvnStatus, func (task gocui.Task) error { - statuses, err := self.c.Git().Svn.CheckBranchStatus(task, "tags") + svnRemoteName := self.c.Git().Svn.GetSvnRemoteName() + + pruned, err := self.c.Git().Svn.PruneStaleRefs("tags") + if err != nil { + return err + } + prunedSet := make(map[string]bool) + for _, p := range pruned { + prunedSet[p] = true + } + + missing, err := self.c.Git().Svn.GetMissingRefs("tags") if err != nil { return err } self.c.OnUIThread(func() error { - for _, tag := range self.c.Model().Tags { - if !tag.IsSvnTag() { - continue - } - // FullRefName() 对 SVN tag 返回 FullRefNameOverride, - // trim 后即 ref 相对路径,与 statuses 的 key 格式一致 - relPath := strings.TrimPrefix(tag.FullRefName(), "refs/remotes/git-svn/") - if status, ok := statuses[relPath]; ok { - tag.StaleStatus = status + refsPrefix := "refs/remotes/" + svnRemoteName + "/" + + if len(prunedSet) > 0 { + self.c.Model().Tags = lo.Filter(self.c.Model().Tags, func(t *models.Tag, _ int) bool { + if !t.IsSvnTag() { + return true + } + relPath := strings.TrimPrefix(t.FullRefName(), refsPrefix) + return prunedSet[relPath] + }) + } + + existingNames := make(map[string]bool) + for _, t := range self.c.Model().Tags { + if t.IsSvnTag() { + relPath := strings.TrimPrefix(t.FullRefName(), refsPrefix) + existingNames[relPath] = true + } + } + for _, m := range missing { + if !existingNames[m] { + self.c.Model().Tags = append(self.c.Model().Tags, + &models.Tag{ + Name: m, + Message: "(SVN tag, not fetched)", + FullRefNameOverride: refsPrefix + m, + StaleStatus: models.SvnBranchStatusMissing, + }) } } - // 仅重绘视图,不 Refresh(TAGS),打断无限刷新链 self.c.PostRefreshUpdate(self.c.Contexts().Tags) return nil }) diff --git a/pkg/gui/controllers/remote_branches_controller.go b/pkg/gui/controllers/remote_branches_controller.go index 74ae209b4..979c7661f 100644 --- a/pkg/gui/controllers/remote_branches_controller.go +++ b/pkg/gui/controllers/remote_branches_controller.go @@ -141,7 +141,7 @@ func (self *RemoteBranchesController) context() *context.RemoteBranchesContext { } func (self *RemoteBranchesController) delete(selectedBranches []*models.RemoteBranch) error { - if len(selectedBranches) > 0 && selectedBranches[0].RemoteName == "git-svn" { + if len(selectedBranches) > 0 && self.c.Git().Sync.GitCommon.IsSvnRepo() && selectedBranches[0].RemoteName == self.c.Git().Svn.GetSvnRemoteName() { return self.deleteSvnRemoteBranches(selectedBranches) } return self.c.Helpers().BranchesHelper.ConfirmDeleteRemote(selectedBranches, true) diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index 4a8f45d24..047fb0e5c 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -146,7 +146,7 @@ func (self *RemotesController) enter(remote *models.Remote) error { self.c.PostRefreshUpdate(remoteBranchesContext) // SVN 自动 stale 检测 - if remote.Name == "git-svn" && self.c.Git().Sync.GitCommon.IsSvnRepo() { + if remote.Name == self.c.Git().Svn.GetSvnRemoteName() && self.c.Git().Sync.GitCommon.IsSvnRepo() { self.checkSvnBranchStatusAsync() } self.c.Context().Push(remoteBranchesContext, types.OnFocusOpts{}) @@ -374,6 +374,9 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam refreshOptions := types.RefreshOptions{ Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}, } + if self.c.Git().Sync.GitCommon.IsSvnRepo() { + refreshOptions.Scope = append(refreshOptions.Scope, types.TAGS) + } if branchName != "" { err = self.c.Git().Branch.New(branchName, remote.Name+"/"+branchName) if err == nil { @@ -393,13 +396,17 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam } } self.c.RefreshFromWorker(refreshOptions) + + if self.c.Git().Sync.GitCommon.IsSvnRepo() && remote.Name == self.c.Git().Svn.GetSvnRemoteName() { + self.checkSvnBranchStatusAsync() + } return err }) } func (self *RemotesController) notGitSvnRemote() *types.DisabledReason { remote := self.context().GetSelected() - if remote != nil && remote.Name == "git-svn" { + if remote != nil && self.c.Git().Sync.GitCommon.IsSvnRepo() && remote.Name == self.c.Git().Svn.GetSvnRemoteName() { return &types.DisabledReason{Text: "Cannot modify git-svn remote"} } return nil diff --git a/pkg/gui/presentation/remote_branches.go b/pkg/gui/presentation/remote_branches.go index bacc93d9a..52a2988bc 100644 --- a/pkg/gui/presentation/remote_branches.go +++ b/pkg/gui/presentation/remote_branches.go @@ -23,12 +23,7 @@ func getRemoteBranchDisplayStrings(b *models.RemoteBranch, diffed bool) []string } name := b.Name - // SVN stale 标记 - switch b.StaleStatus { - case models.SvnBranchStatusStale: - name = b.Name + "⚠" - textStyle = style.FgRed - case models.SvnBranchStatusMissing: + if b.StaleStatus == models.SvnBranchStatusMissing { name = b.Name + "(not fetched)" textStyle = style.FgWhite } diff --git a/pkg/gui/presentation/tags.go b/pkg/gui/presentation/tags.go index c9cf238a5..3bea60fd5 100644 --- a/pkg/gui/presentation/tags.go +++ b/pkg/gui/presentation/tags.go @@ -44,16 +44,10 @@ func getTagDisplayStrings( } name := t.Name - // SVN stale 标记 - if t.IsSvnTag() { - switch t.StaleStatus { - case models.SvnBranchStatusStale: - name = t.Name + "⚠" - textStyle = style.FgRed - case models.SvnBranchStatusMissing: - name = t.Name + "(not fetched)" - textStyle = style.FgWhite - } + + if t.IsSvnTag() && t.StaleStatus == models.SvnBranchStatusMissing { + name = t.Name + "(not fetched)" + textStyle = style.FgWhite } descriptionColor := style.FgYellow