Enhance lazygit function for git-svn

This commit is contained in:
bigfrog 2026-09-04 01:24:50 +08:00
parent 074146926a
commit 5cefdfd75e
21 changed files with 745 additions and 13 deletions

View file

@ -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,

View file

@ -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

View file

@ -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"

View file

@ -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 })...).

View file

@ -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
}

View file

@ -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 "..." <svn-url>/<branchPath>
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
}

View file

@ -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()

View file

@ -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
}

View file

@ -4,6 +4,7 @@ package models
type RemoteBranch struct {
Name string
RemoteName string
StaleStatus SvnBranchStatus
}
func (r *RemoteBranch) FullName() string {

View file

@ -0,0 +1,11 @@
package models
// SvnBranchStatus 表示 SVN 分支的状态
type SvnBranchStatus int
const (
SvnBranchStatusUnknown SvnBranchStatus = itoa
SvnBranchStatusOk // 正常: 本地和 SVN 服务器都存在
SvnBranchStatusStale // Stale本地有引用但 SVN 服务器已删除
SvnBranchStatusMissing // Missing本地未 fetch 但 SVN 服务器上有
)

View file

@ -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/<Name>
// 用于 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/")
}

View file

@ -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

View file

@ -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
}

View file

@ -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(

View file

@ -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},
})
})
}

View file

@ -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
}

View file

@ -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},
})
})
}

View file

@ -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)
}

View file

@ -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)))

View file

@ -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)

View file

@ -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",