diff --git a/pkg/commands/git.go b/pkg/commands/git.go index 69cddfa48..cb9968173 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,8 @@ func NewGitCommandAux( gitCommon := git_commands.NewGitCommon(cmn, version, cmd, osCommand, repoPaths, configCommands, diffRendererConfigManager) + svnCommands := git_commands.NewSvnCommands(gitCommon, cmd) + gitCommon.Svn = svnCommands fileLoader := git_commands.NewFileLoader(gitCommon, cmd, configCommands) statusCommands := git_commands.NewStatusCommands(gitCommon) flowCommands := git_commands.NewFlowCommands(gitCommon) @@ -145,14 +148,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 +175,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..17a48ac69 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,35 @@ 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("--count"). + 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 4a8c85213..1c90fb4dc 100644 --- a/pkg/commands/git_commands/common.go +++ b/pkg/commands/git_commands/common.go @@ -1,6 +1,8 @@ package git_commands import ( + "os" + "path/filepath" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" @@ -8,12 +10,36 @@ import ( type GitCommon struct { *common.Common - version *GitVersion - cmd oscommands.ICmdObjBuilder - os *oscommands.OSCommand - repoPaths *RepoPaths - config *ConfigCommands + version *GitVersion + cmd oscommands.ICmdObjBuilder + os *oscommands.OSCommand + repoPaths *RepoPaths + config *ConfigCommands diffRendererConfigManager *config.DiffRendererConfigManager + IsGitSvnRepo bool + Svn *SvnCommands +} + +func (self *GitCommon) detectGitSvnRepo() { + if self.Common != nil && !self.Common.UserConfig().Git.EnableGitSvnCompat { + self.IsGitSvnRepo = false + return + } + + if self.repoPaths == nil { + self.IsGitSvnRepo = false + return + } + + svnDir := filepath.Join(self.repoPaths.RepoGitDirPath(), "svn") + if info, err := os.Stat(svnDir); err == nil && info.IsDir() { + self.IsGitSvnRepo = true + if self.Common != nil { + self.Common.Log.Info("Detected Git-SVN repository (found .git/svn)") + } + } else { + self.IsGitSvnRepo = false + } } func NewGitCommon( @@ -25,13 +51,19 @@ func NewGitCommon( config *ConfigCommands, diffRendererConfigManager *config.DiffRendererConfigManager, ) *GitCommon { - return &GitCommon{ - Common: cmn, - version: version, - cmd: cmd, - os: osCommand, - repoPaths: repoPaths, - config: config, + gitCommon := &GitCommon{ + Common: cmn, + version: version, + cmd: cmd, + os: osCommand, + repoPaths: repoPaths, + config: config, diffRendererConfigManager: diffRendererConfigManager, } + gitCommon.detectGitSvnRepo() + return gitCommon +} + +func (self *GitCommon) IsSvnRepo() bool { + return self.IsGitSvnRepo } 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..0cd9f2c07 --- /dev/null +++ b/pkg/commands/git_commands/svn_commands.go @@ -0,0 +1,369 @@ +package git_commands + +import ( + "fmt" + "strings" + "sync" + "time" + "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" +) + +// 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 + // CheckBranchStatus 结果缓存(key 为 refType),60秒过期, + // 避免每次界面刷新都发起 svn list 网络请求; + // branches 与 tags 在不同 worker协程并发调用, + // Go map 并发读写会触发不可恢复的 fatal error,故必须加锁 + statusCache map[string]map[string]models.SvnBranchStatus + statusCacheExpiry map[string]time.Time + statusCacheMutex sync.Mutex +} + +func NewSvnCommands(gitCommon *GitCommon, cmd oscommands.ICmdObjBuilder) *SvnCommands { + return &SvnCommands{ + GitCommon: gitCommon, + cmd: cmd, + statusCache: make(map[string]map[string]models.SvnBranchStatus), + statusCacheExpiry: make(map[string]time.Time), + } +} + +// 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.TrimSuffix(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 := strings.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 := []string{"svn", "delete", fmt.Sprintf("%s/%s", svnUrl, branchPath), "-m", fmt.Sprintf("Delete branch %s", branchPath)} + 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[refRelPath]models.SvnBranchStatus, refRelPath 为 ref 相对路 +//(完整 ref 去掉 “refs/remotes/git-svn/” 前缀,如 “branches/proj1/xxx”、”tags/R1.0.0”), +// 与 RemoteBranch.Name / TrimPrefix(tag.FullRefName(), “refs/remotes/git-svn/”)格式一致 +// 结果缓存 60 秒; svn list 全部失败时返回错误(错误不缓存,下次调用自动重试)径 +func (self *SvnCommands) CheckBranchStatus(task gocui.Task, refType string) (map[string]models.SvnBranchStatus, error) { + // 整个方法加锁: branches/tags 两个调用方在不同 worker 协程,Go map并发读写 + // 会直接触发不可恢复的 fatal error;加锁同时保护函数内 + // GetSvnUrl/GetSvnRefMappings 既有缓存在此路径上的并发访问 + self.statusCacheMutex.Lock() + defer self.statusCacheMutex.Unlock() + + // 0. 命中缓存直接返回(60 秒内),避免重复发起 svn list 网络请求 + if cached, ok := self.statusCache[refType]; ok && time.Now().Before(self.statusCacheExpiry[refType]) { + return cached, nil + } + + svnUrl, err := self.GetSvnUrl() + if err != nil { + return nil, err + } + + mappings, _ := self.GetSvnRefMappings() + + // 1. 获取本地 refs(遍历该类型所有 mapping 的 RefsPath, + // 兼容refs 端影射到非标准路径的配置,如 tags = tags/*:refs/remotes/git-svn/releases/*) + localRefs := make(map[string]bool) + for _, m := range mappings { + if m.Type != refType { + continue; + } + output, refErr := self.cmd.New( + NewGitCmd("for-each-ref").Arg("--format=%(refname)").Arg(m.RefsPath).ToArgv(), + ).DontLog().RunWithOutput() + if refErr != nil { + continue; + } + 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 + } + } + } + + // 2. 获取 SVN 服务器上的分支列表(遍历所有 SvnRefMappings) + svnBranches := make(map[string]bool) + svnListAttempted := false + svnListOk := false + for _, m := range mappings { + if m.Type != refType { + continue + } + svnListAttempted = true + // svn list 使用 --non-interactive 防止网络不通时永久阻塞 + svnListOutput, listErr := self.cmd.New( + []string{"svn", "list", "--non-interactive", svnUrl+"/"+m.SvnPath}, + ).DontLog().RunWithOutput() + if listErr == nil { + svnListOk = true + for _, line := range strings.Split(strings.TrimSpace(svnListOutput), "\n") { + 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 + } + } + } + } + + // svn list 全部失败(网络不通、认证失败等)时返回错误并中止, + // 避免把”全部 Stale”的误导性结果当作真实状态展示(错误不缓存,下次自动重时) + if svnListAttempted && !svnListOk { + return nil, fmt.Errorf("svn list failed for all %s paths (network or auth error?)", refType) + } + + // 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 + } + + // 写入缓存(60 秒)。到达此处时 SVN 侧数据必然可信: + // 若有过 svn list 且全部失败,上方已提前返回 + self.statusCache[refType] = result + self.statusCacheExpiry[refType] = time.Now().Add(60 * time.Second) + + return result, nil +} + + diff --git a/pkg/commands/git_commands/sync.go b/pkg/commands/git_commands/sync.go index 0400b4e26..f3f7777ad 100644 --- a/pkg/commands/git_commands/sync.go +++ b/pkg/commands/git_commands/sync.go @@ -29,6 +29,11 @@ type PushOpts struct { } func (self *SyncCommands) PushCmdObj(task gocui.Task, opts PushOpts) (*oscommands.CmdObj, error) { + if self.IsGitSvnRepo { + cmdArgs := NewGitCmd("svn").Arg("dcommit").ToArgv() + return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task), nil + } + if opts.UpstreamBranch != "" && opts.UpstreamRemote == "" { return nil, errors.New(self.Tr.MustSpecifyOriginError) } @@ -63,6 +68,11 @@ func (self *SyncCommands) fetchCommandBuilder(fetchAll bool) *GitCommandBuilder } func (self *SyncCommands) FetchCmdObj(task gocui.Task) *oscommands.CmdObj { + if self.IsGitSvnRepo { + cmdArgs := NewGitCmd("svn").Arg("fetch").ToArgv() + return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task) + } + cmdArgs := self.fetchCommandBuilder(self.UserConfig().Git.FetchAll).ToArgv() cmdObj := self.cmd.New(cmdArgs) @@ -75,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) @@ -96,6 +113,11 @@ type PullOptions struct { } func (self *SyncCommands) Pull(task gocui.Task, opts PullOptions) error { + if self.IsGitSvnRepo { + cmdArgs := NewGitCmd("svn").Arg("rebase").ToArgv() + return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() + } + cmdArgs := NewGitCmd("pull"). Arg("--no-edit"). ArgIf(opts.FastForwardOnly, "--ff-only"). @@ -125,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..a3d390772 100644 --- a/pkg/commands/git_commands/tag_loader.go +++ b/pkg/commands/git_commands/tag_loader.go @@ -2,6 +2,7 @@ package git_commands import ( "regexp" + "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" @@ -13,15 +14,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 +56,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..7f64131e6 --- /dev/null +++ b/pkg/commands/models/svn_branch_status.go @@ -0,0 +1,11 @@ +package models + +// SvnBranchStatus 表示 SVN 分支的状态 +type SvnBranchStatus int + +const ( + SvnBranchStatusUnknown SvnBranchStatus = iota + 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..0fd76bdfa 100644 --- a/pkg/commands/models/tag.go +++ b/pkg/commands/models/tag.go @@ -1,14 +1,26 @@ 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 { + if t.FullRefNameOverride != "" { + return t.FullRefNameOverride + } return "refs/tags/" + t.RefName() } @@ -35,3 +47,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 9738186d9..de3c40a8c 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -358,6 +358,8 @@ type GitConfig struct { RemoteBranchSortOrder string `yaml:"remoteBranchSortOrder" jsonschema:"enum=date,enum=alphabetical"` // 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 `yaml:"enableGitSvnCompat" jsonschema:"default=true"` } type DiffRendererCommandType string @@ -971,6 +973,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { BranchPrefix: "", ParseEmoji: false, TruncateCopiedCommitHashesTo: 12, + EnableGitSvnCompat: true, }, Worktree: WorktreeConfig{ DefaultPath: "", diff --git a/pkg/gui/command_log_panel.go b/pkg/gui/command_log_panel.go index e43f69999..b7d443010 100644 --- a/pkg/gui/command_log_panel.go +++ b/pkg/gui/command_log_panel.go @@ -74,6 +74,12 @@ func (gui *Gui) printCommandLogHeader() { ) fmt.Fprintln(gui.Views.Extras, style.FgCyan.Sprint(introStr)) + if gui.git.Sync.GitCommon.IsSvnRepo() { + fmt.Fprintln(gui.Views.Extras, "Is a Git-SVN repository: Pull=rebase | Push=dcommit") + } else { + fmt.Fprintln(gui.Views.Extras, "Is a Git repository") + } + if gui.c.UserConfig().Gui.ShowRandomTip { fmt.Fprintf( gui.Views.Extras, diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index cfc46b503..f28449160 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}, + Keys: menuKey('l'), + OnPress: func() error { + return self.c.Helpers().Refs.NewBranch( + selectedBranch.FullRefName(), + selectedBranch.RefName(), + "", + ) + }, + }, + { + LabelColumns: []string{self.c.Tr.NewSvnBranch}, + Keys: menuKey('s'), + OnPress: func() error { + return self.newSvnBranch(selectedBranch) + }, + }, + }, + }) + } return self.c.Helpers().Refs.NewBranch(selectedBranch.FullRefName(), selectedBranch.RefName(), "") } @@ -927,3 +952,38 @@ 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("%s", utils.ResolvePlaceholderString( + self.c.Tr.SvnOperationFailed, + map[string]string{"error": err.Error()}, + )) + } + + if err := self.c.Git().Svn.Fetch(); err != nil { + return errors.New(self.c.Tr.SvnFetchFailed) + } + + self.c.RefreshFromWorker(types.RefreshOptions{ + 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..f7290a19e 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() + } + self.refreshView(self.c.Contexts().Tags, env) return nil } @@ -1883,3 +1888,29 @@ func (self *RefreshHelper) savePullRequestsToCache(prs []*models.GithubPullReque self.c.Log.Warnf("error saving GitHub pull request cache: %v", err) } } + +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") + 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 + } + } + // 仅重绘视图,不 Refresh(TAGS),打断无限刷新链 + self.c.PostRefreshUpdate(self.c.Contexts().Tags) + return nil + }) + return nil + }) +} diff --git a/pkg/gui/controllers/remote_branches_controller.go b/pkg/gui/controllers/remote_branches_controller.go index 0d50068f3..74ae209b4 100644 --- a/pkg/gui/controllers/remote_branches_controller.go +++ b/pkg/gui/controllers/remote_branches_controller.go @@ -1,11 +1,13 @@ package controllers import ( + "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/utils" ) @@ -139,6 +141,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 +212,88 @@ 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.MenuItem{ + { + LabelColumns: []string{self.c.Tr.DeleteSvnLocalRef}, + Keys: menuKey('l'), + OnPress: func() error { + return self.deleteSvnLocalRefs(selectedBranches) + }, + }, + { + LabelColumns: []string{self.c.Tr.DeleteSvnBoth}, + Keys: menuKey('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.DeleteLocalRef(refName); err != nil { + return err + } + } + self.c.Contexts().RemoteBranches.CollapseRangeSelectionToTop() + self.c.RefreshFromWorker(types.RefreshOptions{ + 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("%s", utils.ResolvePlaceholderString( + 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.RefreshFromWorker(types.RefreshOptions{ + 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..4a8f45d24 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,35 @@ 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 + } + self.c.OnUIThread(func() error { + for _, branch := range self.c.Model().RemoteBranches { + if branch.RemoteName != "git-svn" { + continue + } + // key 为 ref 相对路径,与 RemoteBranch.Name 格式一致 + if status, ok := statuses[branch.Name]; ok { + branch.StaleStatus = status + } + } + // 仅重绘视图,不 Refresh 重建模型(避免 StaleStatus 被冲掉) + self.c.PostRefreshUpdate(self.c.Contexts().RemoteBranches) + return nil + }) + return nil + }) +} diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index 61b92747b..24c83979a 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -87,6 +87,10 @@ func (self *SyncController) branchCheckedOut(f func(*models.Branch) error) func( } func (self *SyncController) push(currentBranch *models.Branch) error { + if self.c.Git().Sync.GitCommon.IsSvnRepo() { + return self.pushAux(currentBranch, pushOpts{setUpstream: false}) + } + // if we are behind our upstream branch we'll ask if the user wants to force push if currentBranch.IsTrackingRemote() { opts := pushOpts{remoteBranchStoredLocally: currentBranch.RemoteBranchStoredLocally()} @@ -119,14 +123,16 @@ func (self *SyncController) pull(currentBranch *models.Branch) error { action := self.c.Tr.Actions.Pull // if we have no upstream branch we need to set that first - if !currentBranch.IsTrackingRemote() { - return self.c.Helpers().Upstream.PromptForUpstreamWithInitialContent(currentBranch, func(upstream string) error { - if err := self.setCurrentBranchUpstream(upstream); err != nil { - return err - } + if !self.c.Git().Sync.GitCommon.IsSvnRepo() { + if !currentBranch.IsTrackingRemote() { + return self.c.Helpers().Upstream.PromptForUpstreamWithInitialContent(currentBranch, func(upstream string) error { + if err := self.setCurrentBranchUpstream(upstream); err != nil { + return err + } - return self.PullAux(currentBranch, PullFilesOptions{Action: action}) - }) + return self.PullAux(currentBranch, PullFilesOptions{Action: action}) + }) + } } return self.PullAux(currentBranch, PullFilesOptions{Action: action}) @@ -195,6 +201,12 @@ type pushOpts struct { func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts) error { return self.c.WithInlineStatus(currentBranch, types.ItemOperationPushing, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.Push) + + if self.c.Git().Sync.GitCommon.IsSvnRepo() { + opts.force = false + opts.forceWithLease = false + } + err := self.c.Git().Sync.Push( task, git_commands.PushOpts{ @@ -206,6 +218,10 @@ func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts) SetUpstream: opts.setUpstream, }) if err != nil { + if self.c.Git().Sync.GitCommon.IsSvnRepo() { + return fmt.Errorf("Git-SVN dcommit failed: %w", err) + } + if !opts.force && !opts.forceWithLease && strings.Contains(err.Error(), "Updates were rejected") { if opts.remoteBranchStoredLocally { return errors.New(self.c.Tr.UpdatesRejected) diff --git a/pkg/gui/presentation/remote_branches.go b/pkg/gui/presentation/remote_branches.go index 55e4bc113..bacc93d9a 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,10 +22,21 @@ 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))) } - res = append(res, textStyle.Sprint(b.Name)) + res = append(res, textStyle.Sprint(name)) return res } diff --git a/pkg/gui/presentation/tags.go b/pkg/gui/presentation/tags.go index e626c7315..c9cf238a5 100644 --- a/pkg/gui/presentation/tags.go +++ b/pkg/gui/presentation/tags.go @@ -42,12 +42,26 @@ 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 = t.Name + "⚠" + textStyle = style.FgRed + case models.SvnBranchStatusMissing: + name = t.Name + "(not fetched)" + textStyle = style.FgWhite + } + } + descriptionColor := style.FgYellow descriptionStr := descriptionColor.Sprint(t.Description()) itemOperationStr := ItemOperationToString(itemOperation, tr) if itemOperationStr != "" { descriptionStr = style.FgCyan.Sprint(itemOperationStr+" "+Loader(time.Now(), userConfig.Gui.Spinner)) + " " + descriptionStr } - res = append(res, textStyle.Sprint(t.Name), descriptionStr) + res = append(res, textStyle.Sprint(name), descriptionStr) return res } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 9c72b53df..aed005238 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -974,6 +974,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 { @@ -2127,6 +2144,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",