diff --git a/pkg/commands/git.go b/pkg/commands/git.go index 69cddfa48..7cae59953 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -35,6 +35,7 @@ type GitCommand struct { Tag *git_commands.TagCommands WorkingTree *git_commands.WorkingTreeCommands Bisect *git_commands.BisectCommands + Svn *git_commands.SvnCommands Worktree *git_commands.WorktreeCommands Version *git_commands.GitVersion RepoPaths *git_commands.RepoPaths @@ -119,6 +120,7 @@ func NewGitCommandAux( gitCommon := git_commands.NewGitCommon(cmn, version, cmd, osCommand, repoPaths, configCommands, diffRendererConfigManager) + svnCommands := git_commands.NewSvnCommands(gitCommon, cmd) fileLoader := git_commands.NewFileLoader(gitCommon, cmd, configCommands) statusCommands := git_commands.NewStatusCommands(gitCommon) flowCommands := git_commands.NewFlowCommands(gitCommon) @@ -145,14 +147,14 @@ func NewGitCommandAux( gitHubCommands := git_commands.NewGitHubCommands(gitCommon) hostingServiceCommands := git_commands.NewHostingServiceCommand(gitCommon) - branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, configCommands) + branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, configCommands, svnCommands) commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd) commitLoader := git_commands.NewCommitLoader(cmn, cmd, statusCommands.WorkingTreeState, gitCommon) reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd) - remoteLoader := git_commands.NewRemoteLoader(cmn, cmd) + remoteLoader := git_commands.NewRemoteLoader(cmn, cmd, gitCommon) worktreeLoader := git_commands.NewWorktreeLoader(gitCommon) stashLoader := git_commands.NewStashLoader(cmn, cmd) - tagLoader := git_commands.NewTagLoader(cmn, cmd) + tagLoader := git_commands.NewTagLoader(cmn, cmd, gitCommon) return &GitCommand{ Blame: blameCommands, @@ -172,6 +174,7 @@ func NewGitCommandAux( Sync: syncCommands, Tag: tagCommands, Bisect: bisectCommands, + Svn: svnCommands, WorkingTree: workingTreeCommands, Worktree: worktreeCommands, Version: version, diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index b41b0564f..4dd099649 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -45,6 +45,7 @@ type BranchLoader struct { cmd oscommands.ICmdObjBuilder getCurrentBranchInfo func() (BranchInfo, error) config BranchLoaderConfigCommands + svn *SvnCommands } func NewBranchLoader( @@ -53,6 +54,7 @@ func NewBranchLoader( cmd oscommands.ICmdObjBuilder, getCurrentBranchInfo func() (BranchInfo, error), config BranchLoaderConfigCommands, + svn *SvnCommands, ) *BranchLoader { return &BranchLoader{ Common: cmn, @@ -60,6 +62,7 @@ func NewBranchLoader( cmd: cmd, getCurrentBranchInfo: getCurrentBranchInfo, config: config, + svn: svn, } } @@ -125,6 +128,34 @@ func (self *BranchLoader) Load(reflogCommits []*models.Commit, if match != nil { branch.UpstreamRemote = match.Remote branch.UpstreamBranch = match.Merge + } else if !branch.DetachedHead && self.GitCommon.IsSvnRepo() && self.svn != nil { + // git-svn: .git/config 中无 tracking 配置,通过 git-svn-id 反推 + remote, upstreamBranch, err := self.svn.GetSvnUpstream(branch.Name) + if err == nil && remote != "" { + branch.UpstreamRemote = remote + branch.UpstreamBranch = upstreamBranch + + // 计算 ahead/behind (使状态指示器显示✓,而非?) + upstreamRef := fmt.Sprintf("refs/remotes/%s/%s", remote, upstreamBranch) + revOutput, revErr := self.cmd.New( + NewGitCmd("rev-list"). + Arg("--left-right"). + Arg(fmt.Sprintf("%s...%s", branch.FullRefName(), upstreamRef)). + ToArgv(), + ).DontLog().RunWithOutput() + if revErr == nil { + parts := strings.Split(strings.TrimSpace(revOutput), "\t") + if len(parts) == 2 { + branch.AheadForPull = strings.TrimSpace(parts[0]) + branch.BehindForPull = strings.TrimSpace(parts[1]) + branch.AheadForPush = branch.AheadForPull + branch.BehindForPush = branch.BehindForPull + } + } else { + // 远程引用不存在(已被删除),标记为 gone + branch.UpstreamGone = true + } + } } // If the branch already existed, take over its BehindBaseBranch value diff --git a/pkg/commands/git_commands/common.go b/pkg/commands/git_commands/common.go index e282c4d31..15cf94875 100644 --- a/pkg/commands/git_commands/common.go +++ b/pkg/commands/git_commands/common.go @@ -3,7 +3,6 @@ package git_commands import ( "os" "path/filepath" - gogit "github.com/jesseduffield/go-git/v5" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" diff --git a/pkg/commands/git_commands/remote.go b/pkg/commands/git_commands/remote.go index 3b27730fc..e5655c435 100644 --- a/pkg/commands/git_commands/remote.go +++ b/pkg/commands/git_commands/remote.go @@ -51,6 +51,9 @@ func (self *RemoteCommands) UpdateRemoteUrl(remoteName string, updatedUrl string } func (self *RemoteCommands) DeleteRemoteBranch(task gocui.Task, remoteName string, branchNames []string) error { + if remoteName == "git-svn" { + return fmt.Errorf("cannot delete git-svn remote branch via git push; use svn delete instead") + } cmdArgs := NewGitCmd("push"). Arg(remoteName, "--delete"). Arg(lo.Map(branchNames, func(b string, _ int) string { return "refs/heads/" + b })...). diff --git a/pkg/commands/git_commands/remote_loader.go b/pkg/commands/git_commands/remote_loader.go index 2daeb600d..af3c80dd4 100644 --- a/pkg/commands/git_commands/remote_loader.go +++ b/pkg/commands/git_commands/remote_loader.go @@ -16,15 +16,18 @@ import ( type RemoteLoader struct { *common.Common cmd oscommands.ICmdObjBuilder + gitCommon *GitCommon } func NewRemoteLoader( common *common.Common, cmd oscommands.ICmdObjBuilder, + gitCommon *GitCommon, ) *RemoteLoader { return &RemoteLoader{ Common: common, cmd: cmd, + gitCommon: gitCommon, } } @@ -52,6 +55,30 @@ func (self *RemoteLoader) GetRemotes() ([]*models.Remote, error) { remote.Branches = remoteBranchesByRemoteName[remote.Name] } + // SVN 仓库:注入 git-svn 虚拟 remote + // go-git 的 repo.Remotes() 不包含 git-svn,但 git for-each-ref 已经扫描到了 + // refs/remotes/git-svn/*,需要手动注入使其显示在 UI 中 + if self.gitCommon != nil && self.gitCommon.IsSvnRepo() { + tagsPaths, _ := self.gitCommon.Svn.GetTagsRefsPaths() + svnBranches := remoteBranchesByRemoteName["git-svn"] + + // 过滤掉属于 tags 的分支(应由 Tags 界面管理) + var filteredBranches []*models.RemoteBranch + for _, b := range svnBranches { + if !self.isTagRef(b.Name, tagsPaths) { + filteredBranches = append(filteredBranches, b) + } + } + + if len(filteredBranches) > 0 || len(svnBranches) > 0 { + remotes = append(remotes, &models.Remote{ + Name: "git-svn", + Urls: []string{"(git-svn)"}, + Branches: filteredBranches, + }) + } + } + // now lets sort our remotes by name alphabetically slices.SortFunc(remotes, func(a, b *models.Remote) int { // we want origin at the top because we'll be most likely to want it @@ -162,3 +189,13 @@ func (self *RemoteLoader) getRemoteBranchesByRemoteName() (map[string][]*models. return remoteBranchesByRemoteName, nil } + +func (self *RemoteLoader) isTagRef(refName string, tagsPaths []string) bool { + for _, path := range tagsPaths { + fullRef := "refs/remotes/git-svn/" + refName + if strings.HasPrefix(fullRef, path) { + return true + } + } + return false +} diff --git a/pkg/commands/git_commands/svn_commands.go b/pkg/commands/git_commands/svn_commands.go new file mode 100644 index 000000000..2e2a59780 --- /dev/null +++ b/pkg/commands/git_commands/svn_commands.go @@ -0,0 +1,322 @@ +package git_commands + +import ( + "fmt" + "strings" + "time" + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/common" +) + +// SvnRefMapping 表示git-svn配置中的一组路径映射 +// 例如branches = branches/proj1/*:refs/remotes/git-svn/branches/* +// SvnPath 为 "branches/proj1/*", RefsPath 为 "refs/remotes/git-svn/branches/*", Type 为 "branches" +type SvnRefMapping struct { + SvnPath string // SVN 路径,含通配符,如"branches/proj1/*" + RefsPath string // Git refs 路径前缀,如”"refs/remotes/git-svn/branches" + Type string // 类型:"trunk" | "branches" | "tags" +} + +type SvnCommands struct { + *GitCommon + cmd oscommands.ICmdObjBuilder + // 缓存 SVN ref 映射,避免重复解析git config + svnRefMappingsCache *[] SvnRefMapping + svnUrlCache string + svnUrlCacheExpiry time.Time +} + +func NewSvnCommands(gitCommon *GitCommon, cmd oscommands.ICmdObjBuilder) *SvnCommands { + return &SvnCommands{GitCommon: gitCommon, cmd: cmd} +} + +// GetSvnUrl 从git config获取SVN仓库URL +// 返回值如 https://svn.example.com/repo +// 结果缓存60秒 +func (self *SvnCommands) GetSvnUrl() (string, error) { + if self.svnUrlCache != "" && time.Now().Before(self.svnUrlCacheExpiry) { + return self.svnUrlCache, nil + } + + output, err := self.cmd.New( + NewGitCmd("config").Arg("--get", "svn-remote.svn.url").ToArgv(), + ).DontLog().RunWithOutput() + if err != nil { + return "", err + } + self.svnUrlCache = strings.TrimSpace(output) + self.svnUrlCacheExpiry = time.Now().Add(60 * time.Second) + return self.svnUrlCache, nil +} + +// GetSvnRefMappings 解析 svn-remote.svn 配置,返回trunk/branches/tags的refs路径前缀 +// 这是识别SVN tags的核心方法,能处理非标准 tags 目录名(如 "release/proj1") +// 示例配置: +// [svn-remote "svn"] +// url = https://svn.example.com/repo +// fetch = trunk/proj1:refs/remotes/git-svn/trunk +// branches = branches/proj1/*:refs/remotes/git-svn/branches/* +// tags = release/proj1/*:refs/remotes/git-svn/tags/* +// 返回: +// {SvnPath: "trunk/proj1", RefsPath: "refs/remotes/git-svn/trunk", Type: "trunk"} +// {SvnPath: "branches/proj1/*", RefsPath: "refs/remotes/git-svn/branches", Type: "branches"} +// {SvnPath: "release/proj1/*", RefsPath: "refs/remotes/git-svn/tags", Type: "tags"} +func (self *SvnCommands) GetSvnRefMappings() ([]SvnRefMapping, error) { + if self.svnRefMappingsCache != nil { + return *self.svnRefMappingsCache, nil + } + + mappings := []SvnRefMapping{} + + // 1. fetch (trunk) - 格式:trunk/proj1:refs/remotes/git-svn/trunk + fetchVal, err := self.cmd.New( + NewGitCmd("config").Arg("--get", "svn-remote.svn.fetch").ToArgv(), + ).DontLog().RunWithOutput() + if err == nil && strings.TrimSpace(fetchVal) != "" { + mappings = append(mappings, self.parseSvnRefMapping(strings.TrimSpace(fetchVal), "trunk")...) + } + + // 2. branches (可能多组) - git config --get-all + branchesOutput, _ := self.cmd.New( + NewGitCmd("config").Arg("--get-all", "svn-remote.svn.branches").ToArgv(), + ).DontLog().RunWithOutput() + for _, line := range strings.Split(strings.TrimSpace(branchesOutput), "\n") { + if line := strings.TrimSpace(line); line != "" { + mappings = append(mappings, self.parseSvnRefMapping(line, "branches")...) + } + } + + // 3. tags (可能多组) + tagsOutput, _ := self.cmd.New( + NewGitCmd("config").Arg("--get-all", "svn-remote.svn.tags").ToArgv(), + ).DontLog().RunWithOutput() + for _, line := range strings.Split(strings.TrimSpace(tagsOutput), "\n") { + if line := strings.TrimSpace(line); line != "" { + mappings = append(mappings, self.parseSvnRefMapping(line, "tags")...) + } + } + + self.svnRefMappingsCache = &mappings + return mappings, nil +} + +// parseSvnRefMapping 解析单条映射 +// 输入格式: "branches/proj1/*:refs/remotes/git-svn/branches/*" +// 输出:SvnPath="branches/proj1*", RefsPath="refs/remotes/git-svn/branches", Type="branches" +func (self *SvnCommands) parseSvnRefMapping(line, defaultType string) []SvnRefMapping { + parts := strings.Split(line, ":") + if len(parts) != 2 { + return nil + } + svnPath := strings.TrimSpace(parts[0]) + refsPath := strings.TrimSpace(parts[1]) + + // 去掉末尾的 /* 通配符 + svnPathBase := strings.TrimSuffix(svnPath, "/*") + refsPathBase := strings.TrimSuffix(refsPath, "/*") + + return []SvnRefMapping{{ + SvnPath: svnPathBase, + RefsPath: refsPathBase, + Type: defaultType, + }} +} + +// GetTagsRefsPaths 返回所有tags类型的 refs 路径前缀列表 +// 用于 TagLoader 扫描 SVN tags +func (self *SvnCommands) GetTagsRefsPaths() ([]string, error) { + mappings, err := self.GetSvnRefMappings() + if err != nil { + return nil, err + } + var paths []string + for _, m := range mappings { + if m.Type == "tags" { + paths = append(paths, m.RefsPath) + } + } + return paths, nil +} + +// GetSvnUpstream 通过 commit message 中的 git-svn-id 反推本地分支对应的 SVN 远程分支 +// 用于 BranchLoader.Load() 循环中回填 upstream 信息 +func (self *SvnCommands) GetSvnUpstream(branchName string) (string, string, error) { + output, err := self.cmd.New( + NewGitCmd("log"). + Arg("--grep=git-svn-id"). + Arg("--format=%B"). + Arg("-1"). + Arg(branchName). + ToArgv(), + ).DontLog().RunWithOutput() + if err != nil || strings.TrimSpace(output) == "" { + return "", "", nil + } + + svnCommitUrl, ok := self.parseSvnIdLine(output) + if !ok { + return "", "", nil + } + + svnRootUrl, err := self.GetSvnUrl() + if err != nil { + return "", "", err + } + + svnRootUrl = strings.TrimSuffix(svnRootUrl, "/") + relPath := strings.TrimPrefix(svnCommitUrl, svnRootUrl) + relPath = strings.TrimPrefix(relPath, "/") + if relPath == "" { + return "", "", nil + } + + mappings, err := self.GetSvnRefMappings() + if err != nil { + return "", "", err + } + + for _, m := range mappings { + if relPath == m.SvnPath { + upstreamBranch := strings.TrimPrefix(m.RefsPath, "refs/remotes/git-svn") + return "git-svn", 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 + } + } + + return "", "", nil +} + +func (self *SvnCommands) parseSvnIdLine(commitMessage string) (string, bool) { + for _, line := range strings.Split(commitMessage, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "git-svn-id: ") { + rest := string.TrimPrefix(line, "git-svn-id: ") + parts := strings.SplitN(rest, " ", 2) + urlWithRev := parts[0] + atIdx := strings.LastIndex(urlWithRev, "@") + if atIdx > 0 { + return urlWithRev[:atIdx], true + } + return urlWithRev, true + } + } + return "", false +} + +// CreateBranch 使用 git svn branch 在 SVN 仓库创建分支 +// branchName 取决于 clone 时的 --branches 配置 +// 例如 clone 时指定 --branches=branches/proj1,则输入 "xxx" 创建 branches/proj1/xxx +func (self *SvnCommands) CreateBranch(branchName string) error { + cmdArgs := NewGitCmd("svn").Arg("branch").Arg("-m").Arg(fmt.Sprintf("Create branch %s", branchName)).Arg(branchName).ToArgv() + return self.cmd.New(cmdArgs).Run() +} + +// DeleteServerBranch 从 SVN 服务器删除分支 +// branchPath 是相对于 SVN 根的路径,如 "branches/proj1/xxx" +// 实现:执行 svn delete -m "..." / +func (self *SvnCommands) DeleteServerBranch(task gocui.Task, branchPath string) error { + svnUrl, err := self.GetSvnUrl() + if err != nil { + return err + } + cmdArgs := NewGitCmd("svn").Arg("delete").Arg(fmt.Sprintf("%s/%s", svnUrl, branchPath)).Arg("-m").Arg(fmt.Sprintf("Delete branch %s", branchPath)).ToArgv() + return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() +} + +// DeleteLocalRef 仅删除本地远程跟踪引用(refs/remotes/git-svn/xxx) +// 不影响 SVN 服务器,安全操作 +func (self *SvnCommands) DeleteLocalRef(refName string) error { + cmdArgs := NewGitCmd("branch").Arg("-D").Arg("-r").Arg(refName).ToArgv() + return self.cmd.New(cmdArgs).Run() +} + +// Fetch 执行 git svn fetch -all 获取 SVN 更新 +func (self *SvnCommands) Fetch() error { + cmdArgs := NewGitCmd("svn").Arg("fetch").Arg("-all").ToArgv() + return self.cmd.New(cmdArgs).Run() +} + +// CheckBranchStatus 检测本地 refs 和 SVN 服务器的差异 +// refType: "branches" 或 "tags" +// 返回值:map[branchPath]models.SvnBranchStatus, branchPath 如 "branches/proj1/xxx" +// SVN list 使用 --non-interactive 防止网络阻塞,结果不缓存(每次进入时重新检测) +func (self *SvnCommands) CheckBranchStatus(task gocui.Task, refType string) (map[string]models.SvnBranchStatus, error) { + svnUrl, err != self.GetSvnUrl() + if err != nil { + return nil, err + } + + // 1. 获取本地 refs + localRefs := make(map[string]bool) + refsPath := "refs/remotes/git-svn" + refType + output, err := self.cmd.New( + NewGitCmd("for-each-ref").Arg("--format=%(refname)").Arg(refsPath).ToArgv(), + ).DontLog.RunWithOutput() + if err == nil { + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + if line := strings.TrimSpace(line); line != "" { + relPath := strings.TrimPrefix(line, refsPath+"/") + localRefs[relPath] = true + } + } + } + + // 2. 获取 SVN 服务器上的分支列表(遍历所有 SvnRefMappings) + mappings, _ := self.GetSvnRefMappings() + svnBranches := make(map[string]bool) + for _, m := range mappings { + if m.Type != refType { + continue + } + // svn list 使用 --non-interactive 防止网络不通时永久阻塞 + svnListOutput, listErr := self.cmd.New( + NewGitCmd("svn").Arg("list").Arg("--non-interactive").Arg(svnUrl+"/"+m.SvnPath).ToArgv(), + ).DontLog().RunWithOutput() + if listErr == nil { + for _, line := range strings.Split(strings.TrimSpace(svnListOutput), "\n") { + if line := strings.TrimSpace(line); line != "" { + name := strings.TrimSuffix(line, "/") + relPath := m.SvnPath + "/" + name + svnBranches[relPath] = true + } + } + } + } + + // 3. 对比差异 + result := make(map[string]models.SvnBranchStatus) + allPaths := make(map[string]bool) + for k := range localRefs { + allPaths[k] = true + } + for k := range svnBranches { + allPaths[k] = true + } + + for path := range allPaths { + hasLocal := localRefs[path] + hasSvn := svnBranches[path] + var status models.SvnBranchStatus + if hasLocal && hasSvn { + status = models.SvnBranchStatusOk + } else if hasLocal && !hasSvn { + status = models.SvnBranchStatusStale + } else if !hasLocal && hasSvn { + status = models.SvnBranchStatusMissing + } else { + status = models.SvnBranchStatusUnknown + } + result[path] = status + } + + return result, nil +} + + diff --git a/pkg/commands/git_commands/sync.go b/pkg/commands/git_commands/sync.go index ff768d5aa..f3f7777ad 100644 --- a/pkg/commands/git_commands/sync.go +++ b/pkg/commands/git_commands/sync.go @@ -85,6 +85,13 @@ func (self *SyncCommands) Fetch(task gocui.Task) error { } func (self *SyncCommands) FetchBackgroundCmdObj() *oscommands.CmdObj { + if self.IsGitSvnRepo { + cmdArgs := NewGitCmd("svn").Arg("fetch").ToArgv() + cmdObj := self.cmd.New(cmdArgs) + cmdObj.DontLog().FailOnCredentialRequest() + cmdObj.SuppressOutputUnlessError() + return cmdObj + } cmdArgs := self.fetchCommandBuilder(self.UserConfig().Git.FetchAll).ToArgv() cmdObj := self.cmd.New(cmdArgs) @@ -140,6 +147,10 @@ func (self *SyncCommands) FastForward( } func (self *SyncCommands) FetchRemote(task gocui.Task, remoteName string) error { + if self.IsGitSvnRepo && remoteName == "git-svn" { + cmdArgs := NewGitCmd("svn").Arg("fetch").ToArgv() + return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() + } cmdArgs := self.fetchCommandBuilder(false). Arg(remoteName). ToArgv() diff --git a/pkg/commands/git_commands/tag_loader.go b/pkg/commands/git_commands/tag_loader.go index bd05fe4b4..75ff580a7 100644 --- a/pkg/commands/git_commands/tag_loader.go +++ b/pkg/commands/git_commands/tag_loader.go @@ -13,15 +13,18 @@ import ( type TagLoader struct { *common.Common cmd oscommands.ICmdObjBuilder + gitCommon *GitCommon } func NewTagLoader( common *common.Common, cmd oscommands.ICmdObjBuilder, + gitCommon *GitCommon, ) *TagLoader { return &TagLoader{ Common: common, cmd: cmd, + gitCommon: gitCommon, } } @@ -52,5 +55,48 @@ func (self *TagLoader) GetTags() ([]*models.Tag, error) { } }) + // SVN 仓库:追究扫描 refs/remotes/git-svn/tags/* 下的引用 + if self.gitCommon != nil && self.gitCommon.IsSvnRepo() { + tagsPaths, err := self.gitCommon.Svn.GetTagsRefsPaths() + if err == nil && len(tagsPaths) > 0 { + for _, tagsPath := range tagsPaths { + svnTags, err := self.getTagsFromPath(tagsPath) + if err == nil { + tags = append(tags, svnTags...) + } + } + } + } return tags, nil } + +// getTagsFromPath 从指定 refs 路径下获取所有 tag 引用 +func (self *TagLoader) getTagsFromPath(basePath string) ([]*models.Tag, error) { + var svnTags []*models.Tag + cmdArgs := NewGitCmd("for-each-ref"). + Arg("--sort=-creatordate"). + Arg("--format=%(refname)"). + Arg(basePath). + ToArgv() + + err := self.cmd.New(cmdArgs).DontLog().RunAndProcessLines(func(line, string) (bool, error){ + line = strings.TrimSpace(line) + if line == "" { + return false, nil + } + + tagName := strings.TrimPrefix(line, basePath+"/") + if tagName == "" || tagName == line { + return false, nil + } + + svnTags = append(svnTags, &models.Tag{ + Name: tagName, + Message: "(SVN tag)", + FullRefNameOverride: line, + }) + return false, nil + }) + + return svnTags, err +} diff --git a/pkg/commands/models/remote_branch.go b/pkg/commands/models/remote_branch.go index 1e89ef582..d86b562cd 100644 --- a/pkg/commands/models/remote_branch.go +++ b/pkg/commands/models/remote_branch.go @@ -4,6 +4,7 @@ package models type RemoteBranch struct { Name string RemoteName string + StaleStatus SvnBranchStatus } func (r *RemoteBranch) FullName() string { diff --git a/pkg/commands/models/svn_branch_status.go b/pkg/commands/models/svn_branch_status.go new file mode 100644 index 000000000..095fc8c77 --- /dev/null +++ b/pkg/commands/models/svn_branch_status.go @@ -0,0 +1,11 @@ +package models + +// SvnBranchStatus 表示 SVN 分支的状态 +type SvnBranchStatus int + +const ( + SvnBranchStatusUnknown SvnBranchStatus = itoa + SvnBranchStatusOk // 正常: 本地和 SVN 服务器都存在 + SvnBranchStatusStale // Stale:本地有引用但 SVN 服务器已删除 + SvnBranchStatusMissing // Missing:本地未 fetch 但 SVN 服务器上有 +) diff --git a/pkg/commands/models/tag.go b/pkg/commands/models/tag.go index 876e2cd77..e2ff5aa39 100644 --- a/pkg/commands/models/tag.go +++ b/pkg/commands/models/tag.go @@ -1,11 +1,20 @@ package models +import ( + "strings" +) + // Tag : A git tag type Tag struct { Name string // this is either the first line of the message of an annotated tag, or the // first line of a commit message for a lightweight tag Message string + // FullRefNameOverride 当非空时,FullRefName() 返回此值而非默认的 refs/tags/ + // 用于 SVN 类型的 tag,其引用路径在 refs/remotes/git-svn/tags/* 而非 refs/tags/* + FullRefNameOverride string + // StaleStatus 表示 SVN tag 的 stale 状态 + StaleStatus SvnBranchStatus } func (t *Tag) FullRefName() string { @@ -35,3 +44,8 @@ func (t *Tag) URN() string { func (t *Tag) Description() string { return t.Message } + +// IsSvnTag 判断是否为 SVN tag +func (t *Tag) IsSvnTag() bool { + return t.FullRefNameOverride != "" && strings.HasPrefix(t.FullRefNameOverride, "refs/remotes/") +} diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 3f2afe956..e3936ee35 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -359,7 +359,7 @@ type GitConfig struct { // When copying commit hashes to the clipboard, truncate them to this length. Set to 40 to disable truncation. TruncateCopiedCommitHashesTo int `yaml:"truncateCopiedCommitHashesTo"` // If true, will detect if git repository is created using git-svn, is so, will use git svn dcommit/rebase for push/pull operations. - EnableGitSvnCompat bool `toml:"EnableGitSvnCompat"` + EnableGitSvnCompat bool `yaml:"EnableGitSvnCompat" jsonschema:"default=true"` } type DiffRendererCommandType string diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index cfc46b503..a38e72fb3 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -776,6 +776,31 @@ func (self *BranchesController) rename(branch *models.Branch) error { } func (self *BranchesController) newBranch(selectedBranch *models.Branch) error { + if self.c.Git().Sync.GitCommon.IsSvnRepo() { + return self.c.Menu(types.CreateMenuOptions{ + Title: self.c.Tr.NewBranch, + Items: []*types.MenuItem{ + { + LabelColumns: []string{self.c.Tr.NewBranch}, + Key: 'l', + OnPress: func() error { + return self.c.Helpers().Refs.NewBranch( + selectedBranch.FullRefName(), + selectedBranch.RefName(), + "", + ) + }, + }, + { + LabelColumns: []string{self.c.Tr.NewSvnBranch}, + Key: 's', + OnPress: func() error { + return self.newSvnBranch(selectedBranch) + }, + }, + }, + }) + } return self.c.Helpers().Refs.NewBranch(selectedBranch.FullRefName(), selectedBranch.RefName(), "") } @@ -927,3 +952,36 @@ func (self *BranchesController) notMergingIntoYourself(branch *models.Branch) *t return nil } + +// newSvnBranch 提示用户输入 SVN 分支名,然后创建分支并 fetch 刷新 +func (self *BranchesController)newSvnBranch(selectedBranch *models.Branch) error { + self.c.Prompt(types.PromptOpts{ + Title: self.c.Tr.SvnCreateBranchTitle, + InitialContent: "", + HandleConfirm: func(response string) error { + branchName := strings.TrimSpace(response) + if branchName == "" { + return errors.New("branch name cannot be empty") + } + + self.c.LogAction(self.c.Tr.NewSvnBranch) + + return self.c.WithWaitingStatus(self.c.Tr.SvnFetchingStatus, func(task gocui.Task) error { + if err := self.c.Git().Svn.CreateBranch(branchName); err != nil { + return fmt.Errorf(self.c.Tr.SvnOperationFailed, map[string]string{"error": err.Error()}) + } + + if err := self.c.Git().Svn.Fetch(); err != nil { + self.c.ErrorMsg(fmt.Sprintf(self.c.Tr.SvnFetchFailed)) + } + + self.c.Refresh(types.RefreshOptions{ + Mode: types.SYNC, + Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}, + }) + return nil + }) + }, + }) + return nil +} diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index e87edb460..1b225e3d4 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -56,6 +56,9 @@ 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" { + return errors.New("cannot use standard remote delete for git-svn remote branches; use SVN-specific deletion") + } var title string if len(remoteBranches) == 1 { title = utils.ResolvePlaceholderString( diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 730ed9a24..6c28fed74 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1124,6 +1124,11 @@ func (self *RefreshHelper) refreshTags(env refreshEnv) error { self.c.Model().Tags = tags }) + // SVN 自动 stale 检测 (tags) + if self.c.Git().Sync.GitCommon.IsSvnRepo() { + self.checkSvnTagStatusAsync(tags) + } + self.refreshView(self.c.Contexts().Tags, env) return nil } @@ -1883,3 +1888,22 @@ func (self *RefreshHelper) savePullRequestsToCache(prs []*models.GithubPullReque self.c.Log.Warnf("error saving GitHub pull request cache: %v", err) } } + +func (self *RefreshHelper) checkSvnTagStatusAsync(tags []*models.Tag) { + self.c.WithWaitingStatus(self.c.Tr.CheckingSvnStatus, func (task gocui.Task) error { + statuses, err := self.c.Git().Svn.CheckBranchStatus(task, "tags") + if err != nil { + return err + } + for _, tag := range tags { + if tag.IsSvnTag() { + if status, ok := statuses[tag.Name]; ok { + tag.StaleStatus = status + } + } + } + return self.c.Refresh(types.RefreshOptions{ + Scope: []types.RefreshableView{types.TAGS}, + }) + }) +} diff --git a/pkg/gui/controllers/remote_branches_controller.go b/pkg/gui/controllers/remote_branches_controller.go index 0d50068f3..38a766ebf 100644 --- a/pkg/gui/controllers/remote_branches_controller.go +++ b/pkg/gui/controllers/remote_branches_controller.go @@ -1,6 +1,7 @@ package controllers import ( + "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" @@ -139,6 +140,9 @@ func (self *RemoteBranchesController) context() *context.RemoteBranchesContext { } func (self *RemoteBranchesController) delete(selectedBranches []*models.RemoteBranch) error { + if len(selectedBranches) > 0 && selectedBranches[0].RemoteName == "git-svn" { + return self.deleteSvnRemoteBranches(selectedBranches) + } return self.c.Helpers().BranchesHelper.ConfirmDeleteRemote(selectedBranches, true) } @@ -207,3 +211,87 @@ func (self *RemoteBranchesController) newLocalBranch(selectedBranch *models.Remo func (self *RemoteBranchesController) checkoutBranch(selectedBranch *models.RemoteBranch) error { return self.c.Helpers().Refs.CheckoutRemoteBranch(selectedBranch.FullName(), selectedBranch.Name) } + +func (self *RemoteBranchesController) deleteSvnRemoteBranches(selectedBranches []*models.RemoteBranch) error { + var menuTitle string + if len(selectedBranches) == 1 { + menuTitle = utils.ResolvePlaceholderString( + self.c.Tr.SvnDeleteBranchTitle, + map[string]string{"selectedBranchName": selectedBranches[0].Name}, + ) + } else { + menuTitle = self.c.Tr.SvnDeleteBranchTitle + } + + return self.c.Menu(types.CreateMenuOptions{ + Title: menuTitle, + Items: []*types.menuTitle{ + { + LabelColumns: []string{self.c.Tr.DeleteSvnLocalRef}, + Key: 'l', + OnPress: func() error { + return self.deleteSvnLocalRefs(selectedBranches) + }, + }, + { + LabelColumns: []string{self.c.Tr.DeleteSvnBoth}, + Key: 'b', + OnPress: func() error { + return self.confirmDeleteSvnBoth(selectedBranches) + }, + }, + }, + }) +} + +func (self *RemoteBranchesController) deleteSvnLocalRefs(selectedBranches []*models.RemoteBranch) error { + return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(task gocui.Task) error { + for _, branch := range selectedBranches { + refName := branch.RemoteName + "/" + branch.Name + if err := self.c.Git().Svn.DeleteSvnLocalRef(refName); err != nil { + return err + } + } + self.c.Contexts().RemoteBranches.CollapseRangeSelectionToTop() + self.c.Refresh(types.RefreshOptions{ + Mode: types.ASYNC, + Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}, + }) + return nil + }) +} + +func (self *RemoteBranchesController) confirmDeleteSvnBoth(selectedBranches []models.RemoteBranch) error { + var prompt string + if len(selectedBranches) == 1 { + prompt = utils.ResolvePlaceholderString( + self.c.Tr.DeleteSvnBothConfirm, + map[string]string{"branchPath": selectedBranches[0].Name}, + ) + } else { + prompt = fmt.Sprintf("This will delete %d branches from both local refs and SVN server. Continue?", len(selectedBranches)) + } + + self.c.Confirm(types.ConfirmOpts{ + Title: self.c.Tr.SvnDeleteBranchTitle, + Prompt: prompt, + HandleConfirm: func() error { + return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(task gocui.Task,) error { + for _, branch := range selectedBranches { + if err := self.c.Git().Svn.DeleteServerBranch(task, branch.Name); err != nil { + return fmt.Errorf(self.c.Tr.SvnOperationFailed, map[string]string{"error": err.Error()}) + } + refName := branch.RemoteName + "/" + branch.Name + _ = self.c.Git().Svn.DeleteLocalRef(refName) + } + self.c.Contexts().RemoteBranches.CollapseRangeSelectionToTop() + self.c.Refresh(types.RefreshOptions{ + Mode: types.ASYNC, + Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}, + }) + return nil + }) + }, + }) + return nil +} diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index cd05e5ff9..394ba1db1 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -60,7 +60,7 @@ func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*typ { Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: self.withItem(self.remove), - GetDisabledReason: self.require(self.singleItemSelected()), + GetDisabledReason: self.require(self.singleItemSelected(), self.notGitSvnRemote), Description: self.c.Tr.Remove, Tooltip: self.c.Tr.RemoveRemoteTooltip, DisplayOnScreen: true, @@ -68,7 +68,7 @@ func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*typ { Keys: opts.GetKeys(opts.Config.Universal.Edit), Handler: self.withItem(self.edit), - GetDisabledReason: self.require(self.singleItemSelected()), + GetDisabledReason: self.require(self.singleItemSelected(), self.notGitSvnRemote), Description: self.c.Tr.Edit, Tooltip: self.c.Tr.EditRemoteTooltip, DisplayOnScreen: true, @@ -145,6 +145,10 @@ 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() { + self.checkSvnBranchStatusAsync() + } self.c.Context().Push(remoteBranchesContext, types.OnFocusOpts{}) return nil } @@ -392,3 +396,25 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam return err }) } + +func (self *RemotesController) notGitSvnRemote() *types.DisabledReason { + remote := self.Context().GetSelected() + if remote != nil && remote.Name == "git-svn" { + return &types.DisabledReason{Text: "Cannot modify git-svn remote"} + } + return nil +} + +func (self *RemotesController) checkSvnBranchStatusAsync() { + self.c.WithWaitingStatus(self.c.Tr.CheckingSvnStatus, func (task gocui.Task) error { + statuses, err := self.c.Git().Svn.CheckBranchStatus(task, "branches") + if err != nil { + return err + } + // 将结果写入 RemoteBranch 模型的 StaleStatus 字段 + // 通过 Refresh 触发 presentation 层重新渲染 + return self.c.Refresh(types.RefreshOptions{ + Scope: []types.RefreshableView{types.REMOTES}, + }) + }) +} diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index d9ac6a725..24c83979a 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -181,12 +181,6 @@ func (self *SyncController) pullWithLock(task gocui.Task, opts PullFilesOptions) }, ) - if self.c.Git().Sync.GitCommon.IsSvnRepo() { - if err != nil { - return fmt.Errorf("Git-SVN rebase failed: %w", err) - } - } - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseAndSelectHeadCommit(err) } diff --git a/pkg/gui/presentation/remote_branches.go b/pkg/gui/presentation/remote_branches.go index 55e4bc113..1261403fa 100644 --- a/pkg/gui/presentation/remote_branches.go +++ b/pkg/gui/presentation/remote_branches.go @@ -3,6 +3,7 @@ package presentation import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" + "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/samber/lo" ) @@ -21,6 +22,17 @@ func getRemoteBranchDisplayStrings(b *models.RemoteBranch, diffed bool) []string textStyle = theme.DiffTerminalColor } + name := b.Name + // SVN stale 标记 + switch b.StaleStatus { + case models.SvnBranchStatusStale: + name = b.Name + "⚠" + textStyle = style.FgRed + case models.SvnBranchStatusMissing: + name = b.Name + "(not fetched)" + textStyle = style.FgWhite + } + res := make([]string, 0, 2) if icons.IsIconEnabled() { res = append(res, textStyle.Sprint(icons.IconForRemoteBranch(b))) diff --git a/pkg/gui/presentation/tags.go b/pkg/gui/presentation/tags.go index e626c7315..61e1f6e9a 100644 --- a/pkg/gui/presentation/tags.go +++ b/pkg/gui/presentation/tags.go @@ -42,6 +42,20 @@ func getTagDisplayStrings( if icons.IsIconEnabled() { res = append(res, textStyle.Sprint(icons.IconForTag(t))) } + + name := t.Name + // SVN stale 标记 + if t.IsSvnTag() { + switch t.StaleStatus { + case models.SvnBranchStatusStale: + name = b.Name + "⚠" + textStyle = style.FgRed + case models.SvnBranchStatusMissing: + name = b.Name + "(not fetched)" + textStyle = style.FgWhite + } + } + descriptionColor := style.FgYellow descriptionStr := descriptionColor.Sprint(t.Description()) itemOperationStr := ItemOperationToString(itemOperation, tr) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 6d41d31d6..74ad4ec91 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -973,6 +973,23 @@ type TranslationSet struct { UseCurrentChanges string UseIncomingChanges string UseBothChanges string + // --- SVN 相关 --- + NewSvnBranch string + NewSvnBranchPrompt string + SvnBranchCreateSuccess string + SvnFetchFailed string + + DeleteSvnLocalRef string + DeleteSvnBoth string + DeleteSvnBothConfirm string + SvnDeleteLocalRefSuccess string + SvnDeleteBothSuccess string + SvnOperationFailed string + + SvnCreateBranchTitle string + SvnDeleteBranchTitle string + SvnFetchingStatus string + CheckingSvnStatus string } type Bisect struct { @@ -2125,6 +2142,24 @@ func EnglishTranslationSet() *TranslationSet { UseIncomingChanges: "Use incoming changes", UseBothChanges: "Use both", + // --- SVN 相关 --- + NewSvnBranch "New SVN remote branch", + NewSvnBranchPrompt "SVN branch name (relative to --branches config, e.g. my-feature", + SvnBranchCreateSuccess "SVN branch created. Fetching from SVN ...", + SvnFetchFailed "SVN branch created but fetch failed. Run 'git svn fetch' manually.", + + DeleteSvnLocalRef "Delete local ref only (safe)", + DeleteSvnBoth "Delete from SVN server and local ref (dangerous)", + DeleteSvnBothConfirm "This will PERMANENTLY delete '{{.branchPath}}' from both local refs and SVN server. Continue?", + SvnDeleteLocalRefSuccess "Local ref deleted.", + SvnDeleteBothSuccess "SVN branch deleted from both local and SVN server.", + SvnOperationFailed "SVN operation failed: {{.error}}", + + SvnCreateBranchTitle "Create SVN Remote Branch", + SvnDeleteBranchTitle "Delete SVN Remote Branch", + SvnFetchingStatus "Fetching from SVN ...", + CheckingSvnStatus "Checking SVN branch status ...", + Actions: Actions{ // TODO: combine this with the original keybinding descriptions (those are all in lowercase atm) CheckoutCommit: "Checkout commit",