This commit is contained in:
bigfroggit 2026-09-09 06:44:32 +00:00 committed by GitHub
commit cf8bf87ca6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 911 additions and 26 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,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,

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

View file

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

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,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 为 refType60秒过期
// 避免每次界面刷新都发起 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 "..." <svn-url>/<branchPath>
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
}

View file

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

View file

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

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 = iota
SvnBranchStatusOk // 正常: 本地和 SVN 服务器都存在
SvnBranchStatusStale // Stale本地有引用但 SVN 服务器已删除
SvnBranchStatusMissing // Missing本地未 fetch 但 SVN 服务器上有
)

View file

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

View file

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

View file

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

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

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

View file

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

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

View file

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

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

View file

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

View file

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