github.com/qiniu/go-sdk/v7@v7.27.0/sandbox/git_integration_test.go (about)

     1  //go:build integration
     2  
     3  package sandbox
     4  
     5  import (
     6  	"context"
     7  	"errors"
     8  	"fmt"
     9  	"os"
    10  	"strings"
    11  	"testing"
    12  	"time"
    13  
    14  	"github.com/stretchr/testify/assert"
    15  	"github.com/stretchr/testify/require"
    16  )
    17  
    18  // gitTestEnv 把一次 git 集成测试常用的对象打包在一起,避免每个用例重复创建沙箱。
    19  type gitTestEnv struct {
    20  	t   *testing.T
    21  	sb  *Sandbox
    22  	git *Git
    23  	ctx context.Context
    24  }
    25  
    26  // newGitTestEnv 创建一个就绪的沙箱并在测试结束时清理。
    27  func newGitTestEnv(t *testing.T) *gitTestEnv {
    28  	t.Helper()
    29  	c := testClient(t)
    30  	ctx, cancel := context.WithTimeout(context.Background(), 240*time.Second)
    31  	t.Cleanup(cancel)
    32  	sb := createTestSandbox(t, c, ctx)
    33  	return &gitTestEnv{t: t, sb: sb, git: sb.Git(), ctx: ctx}
    34  }
    35  
    36  // initRepo 在沙箱内初始化一个普通仓库并配置作者。
    37  func (e *gitTestEnv) initRepo(path, branch string) {
    38  	e.t.Helper()
    39  	_, err := e.git.Init(e.ctx, path, &InitOptions{InitialBranch: branch})
    40  	require.NoError(e.t, err, "Init %s", path)
    41  	_, err = e.git.ConfigureUser(e.ctx, "Tester", "tester@example.com", &ConfigOptions{
    42  		Scope: GitConfigScopeLocal,
    43  		Path:  path,
    44  	})
    45  	require.NoError(e.t, err, "ConfigureUser")
    46  }
    47  
    48  // writeAndCommit 写入一个文件并完成一次提交。
    49  func (e *gitTestEnv) writeAndCommit(repo, file, content, msg string) {
    50  	e.t.Helper()
    51  	_, err := e.sb.Files().Write(e.ctx, repo+"/"+file, []byte(content))
    52  	require.NoError(e.t, err, "Write %s", file)
    53  	_, err = e.git.Add(e.ctx, repo, nil)
    54  	require.NoError(e.t, err, "Add")
    55  	_, err = e.git.Commit(e.ctx, repo, msg, nil)
    56  	require.NoError(e.t, err, "Commit")
    57  }
    58  
    59  // TestIntegrationGitInitConfigureBranches 覆盖 Init / ConfigureUser / SetConfig / GetConfig /
    60  // Branches(含 unborn 仓库)/ CreateBranch / CheckoutBranch / DeleteBranch 的真实行为。
    61  func TestIntegrationGitInitConfigureBranches(t *testing.T) {
    62  	e := newGitTestEnv(t)
    63  
    64  	repo := "/tmp/it-init"
    65  	_, err := e.git.Init(e.ctx, repo, &InitOptions{InitialBranch: "main"})
    66  	require.NoError(t, err)
    67  
    68  	// unborn 仓库 Branches() 应通过 symbolic-ref fallback 返回 "main"
    69  	br, err := e.git.Branches(e.ctx, repo, nil)
    70  	require.NoError(t, err)
    71  	assert.Empty(t, br.Branches, "unborn 仓库不应有可枚举分支")
    72  	assert.Equal(t, "main", br.CurrentBranch, "unborn 仓库 fallback 后应得到 main")
    73  
    74  	_, err = e.git.ConfigureUser(e.ctx, "Alice", "a@x.io", &ConfigOptions{
    75  		Scope: GitConfigScopeLocal, Path: repo,
    76  	})
    77  	require.NoError(t, err)
    78  	val, err := e.git.GetConfig(e.ctx, "user.name", &ConfigOptions{Scope: GitConfigScopeLocal, Path: repo})
    79  	require.NoError(t, err)
    80  	assert.Equal(t, "Alice", val)
    81  
    82  	// 不存在的 key 必须返回空字符串、无错误
    83  	val, err = e.git.GetConfig(e.ctx, "user.notexist", &ConfigOptions{Scope: GitConfigScopeLocal, Path: repo})
    84  	require.NoError(t, err)
    85  	assert.Empty(t, val)
    86  
    87  	// SetConfig 任意 key
    88  	_, err = e.git.SetConfig(e.ctx, "core.autocrlf", "input", &ConfigOptions{Scope: GitConfigScopeLocal, Path: repo})
    89  	require.NoError(t, err)
    90  	val, err = e.git.GetConfig(e.ctx, "core.autocrlf", &ConfigOptions{Scope: GitConfigScopeLocal, Path: repo})
    91  	require.NoError(t, err)
    92  	assert.Equal(t, "input", val)
    93  
    94  	// Local scope 不给 Path 应直接报错
    95  	_, err = e.git.SetConfig(e.ctx, "k", "v", &ConfigOptions{Scope: GitConfigScopeLocal})
    96  	var ie *InvalidArgumentError
    97  	assert.True(t, errors.As(err, &ie), "Local scope 缺 Path 应返回 InvalidArgumentError")
    98  
    99  	// 创建第一次提交后再做分支操作
   100  	e.writeAndCommit(repo, "README.md", "# init\n", "feat: init")
   101  
   102  	_, err = e.git.CreateBranch(e.ctx, repo, "feature/x", nil)
   103  	require.NoError(t, err)
   104  	br, err = e.git.Branches(e.ctx, repo, nil)
   105  	require.NoError(t, err)
   106  	assert.ElementsMatch(t, []string{"main", "feature/x"}, br.Branches)
   107  	assert.Equal(t, "feature/x", br.CurrentBranch)
   108  
   109  	_, err = e.git.CheckoutBranch(e.ctx, repo, "main", nil)
   110  	require.NoError(t, err)
   111  	_, err = e.git.DeleteBranch(e.ctx, repo, "feature/x", &DeleteBranchOptions{Force: true})
   112  	require.NoError(t, err)
   113  
   114  	br, err = e.git.Branches(e.ctx, repo, nil)
   115  	require.NoError(t, err)
   116  	assert.Equal(t, []string{"main"}, br.Branches)
   117  	assert.Equal(t, "main", br.CurrentBranch)
   118  
   119  	// 空分支名应被参数校验拦下
   120  	_, err = e.git.CreateBranch(e.ctx, repo, "", nil)
   121  	assert.True(t, errors.As(err, &ie))
   122  }
   123  
   124  // TestIntegrationGitAddOptions 覆盖 AddOptions.All 的三种取值(nil 默认 -A、显式 false 用 "."、Files 列表)。
   125  func TestIntegrationGitAddOptions(t *testing.T) {
   126  	e := newGitTestEnv(t)
   127  
   128  	repo := "/tmp/it-add"
   129  	e.initRepo(repo, "main")
   130  	e.writeAndCommit(repo, "seed", "seed\n", "seed")
   131  
   132  	// 子目录 + 顶层各放一个新文件
   133  	_, err := e.sb.Files().Write(e.ctx, repo+"/top.txt", []byte("top\n"))
   134  	require.NoError(t, err)
   135  	_, err = e.sb.Files().Write(e.ctx, repo+"/sub/inner.txt", []byte("inner\n"))
   136  	require.NoError(t, err)
   137  
   138  	// 1) 显式 All=false:相当于 git add . —— 仓库根目录下应递归到子目录(git 行为)。
   139  	//    我们这里只验证不报错且 Status 至少认到一个 staged。
   140  	allFalse := false
   141  	_, err = e.git.Add(e.ctx, repo, &AddOptions{All: &allFalse})
   142  	require.NoError(t, err)
   143  	st, err := e.git.Status(e.ctx, repo, nil)
   144  	require.NoError(t, err)
   145  	assert.GreaterOrEqual(t, st.StagedCount(), 1)
   146  
   147  	// 2) Files=[]:仅暂存指定文件
   148  	_, err = e.git.Reset(e.ctx, repo, &ResetOptions{Paths: []string{"."}})
   149  	require.NoError(t, err)
   150  	_, err = e.git.Add(e.ctx, repo, &AddOptions{Files: []string{"top.txt"}})
   151  	require.NoError(t, err)
   152  	st, err = e.git.Status(e.ctx, repo, nil)
   153  	require.NoError(t, err)
   154  	staged := stagedNames(st)
   155  	assert.Contains(t, staged, "top.txt")
   156  	assert.NotContains(t, staged, "sub/inner.txt")
   157  
   158  	// 3) opts=nil:默认 -A,应把所有未跟踪文件 stage 进来
   159  	_, err = e.git.Add(e.ctx, repo, nil)
   160  	require.NoError(t, err)
   161  	st, err = e.git.Status(e.ctx, repo, nil)
   162  	require.NoError(t, err)
   163  	staged = stagedNames(st)
   164  	assert.Contains(t, staged, "top.txt")
   165  	assert.Contains(t, staged, "sub/inner.txt")
   166  }
   167  
   168  // TestIntegrationGitStatusEdgeCases 验证 parseGitStatus 在真实输出下的健壮性:
   169  // detached HEAD、含空格的文件名、MM(同时 staged + unstaged)、unborn 仓库。
   170  func TestIntegrationGitStatusEdgeCases(t *testing.T) {
   171  	e := newGitTestEnv(t)
   172  
   173  	repo := "/tmp/it-status"
   174  	e.initRepo(repo, "main")
   175  
   176  	// unborn:应能解析出 CurrentBranch=main 且 Detached=false
   177  	st, err := e.git.Status(e.ctx, repo, nil)
   178  	require.NoError(t, err)
   179  	assert.False(t, st.Detached)
   180  	assert.Equal(t, "main", st.CurrentBranch)
   181  
   182  	e.writeAndCommit(repo, "README.md", "# v1\n", "feat: init")
   183  
   184  	// MM:同一文件 staged + 工作区再改一次
   185  	_, err = e.sb.Files().Write(e.ctx, repo+"/README.md", []byte("# v2\n"))
   186  	require.NoError(t, err)
   187  	_, err = e.git.Add(e.ctx, repo, nil)
   188  	require.NoError(t, err)
   189  	_, err = e.sb.Files().Write(e.ctx, repo+"/README.md", []byte("# v2 dirty\n"))
   190  	require.NoError(t, err)
   191  
   192  	// 含空格文件名(未跟踪)
   193  	_, err = e.sb.Files().Write(e.ctx, repo+"/with space.txt", []byte("x\n"))
   194  	require.NoError(t, err)
   195  
   196  	st, err = e.git.Status(e.ctx, repo, nil)
   197  	require.NoError(t, err)
   198  	assert.GreaterOrEqual(t, st.StagedCount(), 1, "README.md 应同时计入 staged")
   199  	assert.GreaterOrEqual(t, st.UnstagedCount(), 1, "README.md 工作区改动应计入 unstaged")
   200  	assert.True(t, st.HasUntracked())
   201  
   202  	byName := map[string]GitFileStatus{}
   203  	for _, f := range st.FileStatus {
   204  		byName[f.Name] = f
   205  	}
   206  	assert.Contains(t, byName, "with space.txt", "含空格的文件名应被正确反引号解析")
   207  	if rm, ok := byName["README.md"]; ok {
   208  		assert.True(t, rm.Staged, "README.md 应有 staged 标记")
   209  	}
   210  
   211  	// detached HEAD
   212  	res, err := e.sb.Commands().Run(e.ctx, "git -C "+repo+" rev-parse HEAD",
   213  		WithEnvs(map[string]string{"GIT_TERMINAL_PROMPT": "0"}))
   214  	require.NoError(t, err)
   215  	sha := strings.TrimSpace(res.Stdout)
   216  	require.NotEmpty(t, sha)
   217  	_, err = e.sb.Commands().Run(e.ctx, "git -C "+repo+" checkout "+sha,
   218  		WithEnvs(map[string]string{"GIT_TERMINAL_PROMPT": "0"}))
   219  	require.NoError(t, err)
   220  	st, err = e.git.Status(e.ctx, repo, nil)
   221  	require.NoError(t, err)
   222  	assert.True(t, st.Detached, "checkout <sha> 后应进入 detached HEAD")
   223  	assert.Empty(t, st.CurrentBranch)
   224  }
   225  
   226  // TestIntegrationGitResetRestore 覆盖 Reset 各种 mode、Restore --staged 与 --source HEAD,以及无效参数组合。
   227  func TestIntegrationGitResetRestore(t *testing.T) {
   228  	e := newGitTestEnv(t)
   229  
   230  	repo := "/tmp/it-reset"
   231  	e.initRepo(repo, "main")
   232  	e.writeAndCommit(repo, "a.txt", "v1\n", "init")
   233  
   234  	// --hard:丢弃工作区改动
   235  	_, err := e.sb.Files().Write(e.ctx, repo+"/a.txt", []byte("dirty\n"))
   236  	require.NoError(t, err)
   237  	_, err = e.git.Reset(e.ctx, repo, &ResetOptions{Mode: GitResetModeHard, Target: "HEAD"})
   238  	require.NoError(t, err)
   239  	got, err := e.sb.Files().ReadText(e.ctx, repo+"/a.txt")
   240  	require.NoError(t, err)
   241  	assert.Equal(t, "v1\n", got)
   242  
   243  	// paths-only:unstage
   244  	_, err = e.sb.Files().Write(e.ctx, repo+"/a.txt", []byte("staged\n"))
   245  	require.NoError(t, err)
   246  	_, err = e.git.Add(e.ctx, repo, nil)
   247  	require.NoError(t, err)
   248  	_, err = e.git.Reset(e.ctx, repo, &ResetOptions{Paths: []string{"a.txt"}})
   249  	require.NoError(t, err)
   250  	st, err := e.git.Status(e.ctx, repo, nil)
   251  	require.NoError(t, err)
   252  	assert.Equal(t, 0, st.StagedCount())
   253  
   254  	// Mode + Paths 同时给 → InvalidArgumentError
   255  	_, err = e.git.Reset(e.ctx, repo, &ResetOptions{Mode: GitResetModeHard, Paths: []string{"a.txt"}})
   256  	var ie *InvalidArgumentError
   257  	assert.True(t, errors.As(err, &ie))
   258  
   259  	// 非法 mode → InvalidArgumentError
   260  	_, err = e.git.Reset(e.ctx, repo, &ResetOptions{Mode: GitResetMode("bogus")})
   261  	assert.True(t, errors.As(err, &ie))
   262  
   263  	// Restore --staged:仅取消暂存,工作区保留
   264  	_, err = e.sb.Files().Write(e.ctx, repo+"/a.txt", []byte("again\n"))
   265  	require.NoError(t, err)
   266  	_, err = e.git.Add(e.ctx, repo, nil)
   267  	require.NoError(t, err)
   268  	staged := true
   269  	_, err = e.git.Restore(e.ctx, repo, &RestoreOptions{Paths: []string{"a.txt"}, Staged: &staged})
   270  	require.NoError(t, err)
   271  	got, err = e.sb.Files().ReadText(e.ctx, repo+"/a.txt")
   272  	require.NoError(t, err)
   273  	assert.Equal(t, "again\n", got, "Restore --staged 不应改动工作区")
   274  	st, err = e.git.Status(e.ctx, repo, nil)
   275  	require.NoError(t, err)
   276  	assert.Equal(t, 0, st.StagedCount())
   277  
   278  	// Restore --source HEAD:把工作区恢复回 HEAD
   279  	_, err = e.git.Restore(e.ctx, repo, &RestoreOptions{Paths: []string{"a.txt"}, Source: "HEAD"})
   280  	require.NoError(t, err)
   281  	got, err = e.sb.Files().ReadText(e.ctx, repo+"/a.txt")
   282  	require.NoError(t, err)
   283  	assert.Equal(t, "v1\n", got)
   284  
   285  	// Restore Paths 为空 → 错
   286  	_, err = e.git.Restore(e.ctx, repo, &RestoreOptions{})
   287  	assert.Error(t, err)
   288  }
   289  
   290  // TestIntegrationGitCommitOptions 覆盖 CommitOptions 的 Author 覆盖与 AllowEmpty。
   291  func TestIntegrationGitCommitOptions(t *testing.T) {
   292  	e := newGitTestEnv(t)
   293  	repo := "/tmp/it-commit"
   294  	e.initRepo(repo, "main")
   295  	e.writeAndCommit(repo, "seed", "seed\n", "seed")
   296  
   297  	// 空消息 → 拒绝
   298  	_, err := e.git.Commit(e.ctx, repo, "", nil)
   299  	var ie *InvalidArgumentError
   300  	assert.True(t, errors.As(err, &ie))
   301  
   302  	// Author 覆盖
   303  	_, err = e.sb.Files().Write(e.ctx, repo+"/b.txt", []byte("b\n"))
   304  	require.NoError(t, err)
   305  	_, err = e.git.Add(e.ctx, repo, nil)
   306  	require.NoError(t, err)
   307  	_, err = e.git.Commit(e.ctx, repo, "feat: b", &CommitOptions{
   308  		AuthorName: "Bob", AuthorEmail: "bob@x.io",
   309  	})
   310  	require.NoError(t, err)
   311  	res, err := e.sb.Commands().Run(e.ctx, "git -C "+repo+" log -1 --pretty=%an",
   312  		WithEnvs(map[string]string{"GIT_TERMINAL_PROMPT": "0"}))
   313  	require.NoError(t, err)
   314  	assert.Equal(t, "Bob", strings.TrimSpace(res.Stdout))
   315  
   316  	// AllowEmpty=true:在 clean 工作区上仍能提交
   317  	_, err = e.git.Commit(e.ctx, repo, "chore: empty", &CommitOptions{AllowEmpty: true})
   318  	require.NoError(t, err)
   319  
   320  	// AllowEmpty=false(默认):clean 工作区会失败
   321  	_, err = e.git.Commit(e.ctx, repo, "chore: should fail", nil)
   322  	assert.Error(t, err)
   323  }
   324  
   325  // TestIntegrationGitRemoteAndPushPull 覆盖 RemoteAdd(含 Overwrite)/ RemoteGet(缺失)/
   326  // Push(SetUpstream 默认 true 与显式 false) / Pull / 参数校验。
   327  func TestIntegrationGitRemoteAndPushPull(t *testing.T) {
   328  	e := newGitTestEnv(t)
   329  
   330  	repo := "/tmp/it-remote"
   331  	bare := "/tmp/it-remote.git"
   332  	consumer := "/tmp/it-consumer"
   333  	e.initRepo(repo, "main")
   334  	e.writeAndCommit(repo, "README.md", "# v1\n", "init")
   335  	_, err := e.git.Init(e.ctx, bare, &InitOptions{Bare: true, InitialBranch: "main"})
   336  	require.NoError(t, err)
   337  
   338  	// RemoteGet 不存在 → 空字符串、无错
   339  	url, err := e.git.RemoteGet(e.ctx, repo, "nope", nil)
   340  	require.NoError(t, err)
   341  	assert.Empty(t, url)
   342  
   343  	// RemoteAdd 占位 URL,再 Overwrite 改成 bare 路径
   344  	_, err = e.git.RemoteAdd(e.ctx, repo, "origin", "https://example.com/x.git", nil)
   345  	require.NoError(t, err)
   346  	_, err = e.git.RemoteAdd(e.ctx, repo, "origin", bare, &RemoteAddOptions{Overwrite: true})
   347  	require.NoError(t, err)
   348  	url, err = e.git.RemoteGet(e.ctx, repo, "origin", nil)
   349  	require.NoError(t, err)
   350  	assert.Equal(t, bare, url)
   351  
   352  	// Push 不给 Remote 但给 Branch:单 remote 仓库下应自动选中 origin 推送成功
   353  	_, err = e.git.Push(e.ctx, repo, &PushOptions{Branch: "main"})
   354  	require.NoError(t, err, "Push 应能在单 remote 仓库下自动选中 origin")
   355  
   356  	// Push 显式 SetUpstream=false(不写 upstream)
   357  	noUpstream := false
   358  	_, err = e.git.Push(e.ctx, repo, &PushOptions{
   359  		Remote: "origin", Branch: "main", SetUpstream: &noUpstream,
   360  	})
   361  	require.NoError(t, err)
   362  
   363  	// Clone 出 consumer 仓库
   364  	_, err = e.git.Clone(e.ctx, bare, &CloneOptions{Path: consumer})
   365  	require.NoError(t, err)
   366  
   367  	// 主仓再 push 一次(这次走 SetUpstream 默认 true)
   368  	e.writeAndCommit(repo, "CHANGELOG.md", "v1\n", "docs")
   369  	_, err = e.git.Push(e.ctx, repo, &PushOptions{Remote: "origin", Branch: "main"})
   370  	require.NoError(t, err)
   371  
   372  	// consumer Pull —— 不给 Branch 也不给 Remote,应通过 hasUpstream 走默认路径
   373  	_, err = e.git.Pull(e.ctx, consumer, &PullOptions{})
   374  	require.NoError(t, err)
   375  	exists, err := e.sb.Files().Exists(e.ctx, consumer+"/CHANGELOG.md")
   376  	require.NoError(t, err)
   377  	assert.True(t, exists)
   378  
   379  	// Pull 不给 Remote 但给 Branch:单 remote 仓库下应自动选中 origin 拉取成功
   380  	_, err = e.git.Pull(e.ctx, consumer, &PullOptions{Branch: "main"})
   381  	require.NoError(t, err, "Pull 应能在单 remote 仓库下自动选中 origin")
   382  }
   383  
   384  // TestIntegrationGitPullMissingUpstream 覆盖 Pull 在没有 upstream 的本地分支上抛出 GitUpstreamError。
   385  func TestIntegrationGitPullMissingUpstream(t *testing.T) {
   386  	e := newGitTestEnv(t)
   387  	repo := "/tmp/it-no-upstream"
   388  	e.initRepo(repo, "main")
   389  	e.writeAndCommit(repo, "a", "a\n", "init")
   390  
   391  	_, err := e.git.Pull(e.ctx, repo, &PullOptions{})
   392  	var ue *GitUpstreamError
   393  	assert.True(t, errors.As(err, &ue), "缺 upstream 时 Pull 应返回 GitUpstreamError,实际: %T %v", err, err)
   394  }
   395  
   396  // TestIntegrationGitPushPullBranchWithoutRemote 覆盖在多 remote / 无 remote 仓库上
   397  // 仅指定 Branch 不指定 Remote 时,SDK 应返回 InvalidArgumentError 而不是把分支名当 remote 名传给 git。
   398  func TestIntegrationGitPushPullBranchWithoutRemote(t *testing.T) {
   399  	e := newGitTestEnv(t)
   400  
   401  	// 1) 无 remote 仓库
   402  	noRemote := "/tmp/it-no-remote"
   403  	e.initRepo(noRemote, "main")
   404  	e.writeAndCommit(noRemote, "a", "a\n", "init")
   405  
   406  	_, err := e.git.Push(e.ctx, noRemote, &PushOptions{Branch: "main"})
   407  	var ie1 *InvalidArgumentError
   408  	assert.True(t, errors.As(err, &ie1), "无 remote 时 Push(Branch:main) 应返回 InvalidArgumentError,实际: %T %v", err, err)
   409  
   410  	_, err = e.git.Pull(e.ctx, noRemote, &PullOptions{Branch: "main"})
   411  	var ie2 *InvalidArgumentError
   412  	assert.True(t, errors.As(err, &ie2), "无 remote 时 Pull(Branch:main) 应返回 InvalidArgumentError,实际: %T %v", err, err)
   413  
   414  	// 2) 多 remote 仓库
   415  	multi := "/tmp/it-multi-remote"
   416  	e.initRepo(multi, "main")
   417  	e.writeAndCommit(multi, "a", "a\n", "init")
   418  	bare1 := "/tmp/it-multi-bare1.git"
   419  	bare2 := "/tmp/it-multi-bare2.git"
   420  	_, err = e.git.Init(e.ctx, bare1, &InitOptions{Bare: true, InitialBranch: "main"})
   421  	require.NoError(t, err)
   422  	_, err = e.git.Init(e.ctx, bare2, &InitOptions{Bare: true, InitialBranch: "main"})
   423  	require.NoError(t, err)
   424  	_, err = e.git.RemoteAdd(e.ctx, multi, "origin", bare1, nil)
   425  	require.NoError(t, err)
   426  	_, err = e.git.RemoteAdd(e.ctx, multi, "backup", bare2, nil)
   427  	require.NoError(t, err)
   428  
   429  	_, err = e.git.Push(e.ctx, multi, &PushOptions{Branch: "main"})
   430  	var ie3 *InvalidArgumentError
   431  	assert.True(t, errors.As(err, &ie3), "多 remote 时 Push(Branch:main) 应返回 InvalidArgumentError,实际: %T %v", err, err)
   432  
   433  	_, err = e.git.Pull(e.ctx, multi, &PullOptions{Branch: "main"})
   434  	var ie4 *InvalidArgumentError
   435  	assert.True(t, errors.As(err, &ie4), "多 remote 时 Pull(Branch:main) 应返回 InvalidArgumentError,实际: %T %v", err, err)
   436  
   437  	// 显式给 Remote 时仍可正常工作
   438  	_, err = e.git.Push(e.ctx, multi, &PushOptions{Remote: "origin", Branch: "main"})
   439  	require.NoError(t, err, "多 remote 仓库下显式 Remote 应能正常 Push")
   440  }
   441  
   442  // TestIntegrationGitPushPullSurfacesRepoErrors 验证当 path 不是 git 仓库时,
   443  // Push/Pull 直接返回真实仓库错误,而不是被 autoSelectRemote 吞成 InvalidArgumentError。
   444  func TestIntegrationGitPushPullSurfacesRepoErrors(t *testing.T) {
   445  	e := newGitTestEnv(t)
   446  	notRepo := "/tmp/it-not-a-repo"
   447  	_, err := e.sb.Files().MakeDir(e.ctx, notRepo)
   448  	require.NoError(t, err)
   449  
   450  	_, err = e.git.Push(e.ctx, notRepo, &PushOptions{Branch: "main"})
   451  	require.Error(t, err)
   452  	var ie1 *InvalidArgumentError
   453  	assert.False(t, errors.As(err, &ie1), "非仓库目录的 Push 错误不应被掩盖成 InvalidArgumentError,实际: %T %v", err, err)
   454  
   455  	_, err = e.git.Pull(e.ctx, notRepo, &PullOptions{Branch: "main"})
   456  	require.Error(t, err)
   457  	var ie2 *InvalidArgumentError
   458  	assert.False(t, errors.As(err, &ie2), "非仓库目录的 Pull 错误不应被掩盖成 InvalidArgumentError,实际: %T %v", err, err)
   459  }
   460  
   461  // TestIntegrationGitDangerouslyAuthenticate 验证凭证确实被 git credential helper 持久化。
   462  func TestIntegrationGitDangerouslyAuthenticate(t *testing.T) {
   463  	e := newGitTestEnv(t)
   464  
   465  	// Protocol 非 https → InvalidArgumentError
   466  	_, err := e.git.DangerouslyAuthenticate(e.ctx, &AuthenticateOptions{
   467  		Username: "u", Password: "p", Host: "h", Protocol: "ssh",
   468  	})
   469  	var ie *InvalidArgumentError
   470  	assert.True(t, errors.As(err, &ie))
   471  
   472  	// 写入凭证(仅在沙箱内)
   473  	_, err = e.git.DangerouslyAuthenticate(e.ctx, &AuthenticateOptions{
   474  		Username: "demo-user", Password: "demo-token", Host: "fake-host.example",
   475  	})
   476  	require.NoError(t, err)
   477  
   478  	// 用 `git credential fill` 验证:输入 protocol/host,git 会从 store 中回填 username/password
   479  	input := "protocol=https\nhost=fake-host.example\n\n"
   480  	res, err := e.sb.Commands().Run(e.ctx,
   481  		"printf %s '"+input+"' | git credential fill",
   482  		WithEnvs(map[string]string{"GIT_TERMINAL_PROMPT": "0"}),
   483  	)
   484  	require.NoError(t, err)
   485  	assert.Equal(t, 0, res.ExitCode, "credential fill 应成功,stdout=%q stderr=%q", res.Stdout, res.Stderr)
   486  	assert.Contains(t, res.Stdout, "username=demo-user")
   487  	assert.Contains(t, res.Stdout, "password=demo-token")
   488  }
   489  
   490  // TestIntegrationGitCloneStripsCredentials 验证默认情况下 clone 完成后 origin URL 不再带凭证。
   491  // 仅当提供 QINIU_GIT_REPO_URL/USERNAME/PASSWORD 时执行。
   492  func TestIntegrationGitCloneStripsCredentials(t *testing.T) {
   493  	repoURL, username, password := getGitCredsFromEnv(t)
   494  	e := newGitTestEnv(t)
   495  
   496  	clonePath := "/tmp/it-clone-strip"
   497  	_, err := e.git.Clone(e.ctx, repoURL, &CloneOptions{
   498  		Path: clonePath, Depth: 1, Username: username, Password: password,
   499  	})
   500  	require.NoError(t, err)
   501  	got, err := e.git.RemoteGet(e.ctx, clonePath, "origin", nil)
   502  	require.NoError(t, err)
   503  	assert.NotContains(t, got, username+":", "默认应剥离 origin URL 中的凭证")
   504  	assert.NotContains(t, got, password)
   505  }
   506  
   507  // TestIntegrationGitCloneBranch 用沙箱内自建 bare repo 模拟一个多分支 remote,
   508  // 验证 CloneOptions.Branch 能让 HEAD 指向指定分支,并且默认情况下 origin URL 不残留凭证。
   509  func TestIntegrationGitCloneBranch(t *testing.T) {
   510  	e := newGitTestEnv(t)
   511  
   512  	source := "/tmp/it-clone-src"
   513  	bare := "/tmp/it-clone-src.git"
   514  	dst := "/tmp/it-clone-dst"
   515  
   516  	e.initRepo(source, "main")
   517  	e.writeAndCommit(source, "main.txt", "main\n", "feat: main")
   518  
   519  	// 在 source 上再造一个 release 分支并提交
   520  	_, err := e.git.CreateBranch(e.ctx, source, "release", nil)
   521  	require.NoError(t, err)
   522  	e.writeAndCommit(source, "release.txt", "release\n", "feat: release")
   523  	_, err = e.git.CheckoutBranch(e.ctx, source, "main", nil)
   524  	require.NoError(t, err)
   525  
   526  	// 把 source push 到 bare 当作"远端"
   527  	_, err = e.git.Init(e.ctx, bare, &InitOptions{Bare: true, InitialBranch: "main"})
   528  	require.NoError(t, err)
   529  	_, err = e.git.RemoteAdd(e.ctx, source, "origin", bare, nil)
   530  	require.NoError(t, err)
   531  	_, err = e.git.Push(e.ctx, source, &PushOptions{Remote: "origin", Branch: "main"})
   532  	require.NoError(t, err)
   533  	_, err = e.git.Push(e.ctx, source, &PushOptions{Remote: "origin", Branch: "release"})
   534  	require.NoError(t, err)
   535  
   536  	// 走 CloneOptions.Branch 拉取 release
   537  	_, err = e.git.Clone(e.ctx, bare, &CloneOptions{Path: dst, Branch: "release"})
   538  	require.NoError(t, err)
   539  
   540  	// HEAD 应指向 release
   541  	br, err := e.git.Branches(e.ctx, dst, nil)
   542  	require.NoError(t, err)
   543  	assert.Equal(t, "release", br.CurrentBranch)
   544  	exists, err := e.sb.Files().Exists(e.ctx, dst+"/release.txt")
   545  	require.NoError(t, err)
   546  	assert.True(t, exists, "Branch=release 时 release.txt 应存在")
   547  
   548  	// origin URL 不应包含任何 user info(无凭证残留)
   549  	origin, err := e.git.RemoteGet(e.ctx, dst, "origin", nil)
   550  	require.NoError(t, err)
   551  	assert.NotContains(t, origin, "@")
   552  }
   553  
   554  // TestIntegrationGitRemoteAddFetch 验证 RemoteAddOptions.Fetch=true 在添加 remote
   555  // 后立即拉取引用,使 origin/<branch> 可被 rev-parse 解析。
   556  func TestIntegrationGitRemoteAddFetch(t *testing.T) {
   557  	e := newGitTestEnv(t)
   558  
   559  	source := "/tmp/it-fetch-src"
   560  	bare := "/tmp/it-fetch-src.git"
   561  	consumer := "/tmp/it-fetch-consumer"
   562  
   563  	e.initRepo(source, "main")
   564  	e.writeAndCommit(source, "a.txt", "a\n", "init")
   565  	_, err := e.git.Init(e.ctx, bare, &InitOptions{Bare: true, InitialBranch: "main"})
   566  	require.NoError(t, err)
   567  	_, err = e.git.RemoteAdd(e.ctx, source, "origin", bare, nil)
   568  	require.NoError(t, err)
   569  	_, err = e.git.Push(e.ctx, source, &PushOptions{Remote: "origin", Branch: "main"})
   570  	require.NoError(t, err)
   571  
   572  	// consumer:先 init 再 RemoteAdd(Fetch=false) → 不应能解析 origin/main
   573  	_, err = e.git.Init(e.ctx, consumer, &InitOptions{InitialBranch: "main"})
   574  	require.NoError(t, err)
   575  	_, err = e.git.RemoteAdd(e.ctx, consumer, "origin", bare, nil)
   576  	require.NoError(t, err)
   577  
   578  	res, err := e.sb.Commands().Run(e.ctx, "git -C "+consumer+" rev-parse --verify -q origin/main",
   579  		WithEnvs(map[string]string{"GIT_TERMINAL_PROMPT": "0"}))
   580  	require.NoError(t, err)
   581  	assert.NotEqual(t, 0, res.ExitCode, "Fetch=false 时 origin/main 不应可解析")
   582  
   583  	// 用 Overwrite=true + Fetch=true 重新加一次(同一个 origin)
   584  	_, err = e.git.RemoteAdd(e.ctx, consumer, "origin", bare, &RemoteAddOptions{
   585  		Overwrite: true, Fetch: true,
   586  	})
   587  	require.NoError(t, err)
   588  
   589  	res, err = e.sb.Commands().Run(e.ctx, "git -C "+consumer+" rev-parse --verify -q origin/main",
   590  		WithEnvs(map[string]string{"GIT_TERMINAL_PROMPT": "0"}))
   591  	require.NoError(t, err)
   592  	assert.Equal(t, 0, res.ExitCode, "Fetch=true 时 origin/main 应已被拉取,stderr=%q", res.Stderr)
   593  	assert.NotEmpty(t, strings.TrimSpace(res.Stdout))
   594  }
   595  
   596  // TestIntegrationGitOptionsEnvsCwdTimeout 验证 GitOptions.Envs / Cwd / Timeout 的真实生效路径:
   597  //   - Envs:自定义 GIT_AUTHOR_DATE 应作用到 commit;同时 GIT_TERMINAL_PROMPT=0 不会被调用方覆盖。
   598  //   - Cwd:在 cwd 下用相对路径 Init 应在 cwd 内建出仓库。
   599  //   - Timeout:极短超时应让命令失败而非挂住。
   600  func TestIntegrationGitOptionsEnvsCwdTimeout(t *testing.T) {
   601  	e := newGitTestEnv(t)
   602  
   603  	// --- Envs ---
   604  	envRepo := "/tmp/it-envs"
   605  	e.initRepo(envRepo, "main")
   606  	_, err := e.sb.Files().Write(e.ctx, envRepo+"/a.txt", []byte("a\n"))
   607  	require.NoError(t, err)
   608  	_, err = e.git.Add(e.ctx, envRepo, nil)
   609  	require.NoError(t, err)
   610  
   611  	authorDate := "2020-01-02T03:04:05+00:00"
   612  	// 故意把 GIT_TERMINAL_PROMPT 置为 "1",验证 SDK 默认值会被写在最后从而覆盖回 "0"。
   613  	_, err = e.git.Commit(e.ctx, envRepo, "feat: dated", &CommitOptions{
   614  		GitOptions: GitOptions{
   615  			Envs: map[string]string{
   616  				"GIT_AUTHOR_DATE":     authorDate,
   617  				"GIT_COMMITTER_DATE":  authorDate,
   618  				"GIT_TERMINAL_PROMPT": "1", // 不应生效,SDK 必须强制 0
   619  			},
   620  		},
   621  	})
   622  	require.NoError(t, err)
   623  	res, err := e.sb.Commands().Run(e.ctx, "git -C "+envRepo+" log -1 --pretty=%aI",
   624  		WithEnvs(map[string]string{"GIT_TERMINAL_PROMPT": "0"}))
   625  	require.NoError(t, err)
   626  	assert.Equal(t, "2020-01-02T03:04:05+00:00", strings.TrimSpace(res.Stdout))
   627  
   628  	// --- Cwd ---
   629  	cwdParent := "/tmp/it-cwd"
   630  	_, err = e.sb.Commands().Run(e.ctx, "mkdir -p "+cwdParent,
   631  		WithEnvs(map[string]string{"GIT_TERMINAL_PROMPT": "0"}))
   632  	require.NoError(t, err)
   633  	_, err = e.git.Init(e.ctx, "nested", &InitOptions{
   634  		InitialBranch: "main",
   635  		GitOptions:    GitOptions{Cwd: cwdParent},
   636  	})
   637  	require.NoError(t, err)
   638  	exists, err := e.sb.Files().Exists(e.ctx, cwdParent+"/nested/.git/HEAD")
   639  	require.NoError(t, err)
   640  	assert.True(t, exists, "Cwd 下相对路径 Init 应在 %s/nested 建出仓库", cwdParent)
   641  
   642  	// --- Timeout ---
   643  	// 极短超时下任何 git 命令都应直接失败/超时,而不是挂住。
   644  	_, err = e.git.Status(e.ctx, envRepo, &GitOptions{Timeout: 1 * time.Nanosecond})
   645  	assert.Error(t, err, "Timeout=1ns 应导致命令失败")
   646  }
   647  
   648  // TestIntegrationGitPushPullWithCredentials 端到端验证带凭证的 Clone → Commit → Push → Pull
   649  // 完整走通真实远端:
   650  //  1. Clone 仓库到沙箱(带凭证、默认剥离)
   651  //  2. 创建一个唯一分支(it-push-<timestamp>-<pid>)
   652  //  3. 写文件 + commit + Push(SetUpstream=true)到远端
   653  //  4. Re-clone 一份新仓库 + Pull 该分支,验证文件可见
   654  //  5. t.Cleanup 通过 push --delete 清理远端分支
   655  //
   656  // 仅当 QINIU_GIT_REPO_URL/USERNAME/PASSWORD 都配置了写权限时才执行。
   657  // 请确保使用专用的"测试仓库",不要指向业务仓库。
   658  func TestIntegrationGitPushPullWithCredentials(t *testing.T) {
   659  	repoURL, username, password := getGitCredsFromEnv(t)
   660  	e := newGitTestEnv(t)
   661  
   662  	branch := fmt.Sprintf("it-push-%d-%d", time.Now().UnixNano(), os.Getpid())
   663  	work := "/tmp/it-rpush-work"
   664  	verify := "/tmp/it-rpush-verify"
   665  	marker := fmt.Sprintf("sandbox integration test %s\n", branch)
   666  	markerFile := branch + ".txt"
   667  
   668  	// 1) Clone 工作仓库(带凭证;默认剥离)
   669  	_, err := e.git.Clone(e.ctx, repoURL, &CloneOptions{
   670  		Path: work, Depth: 1, Username: username, Password: password,
   671  	})
   672  	require.NoError(t, err)
   673  
   674  	originURL, err := e.git.RemoteGet(e.ctx, work, "origin", nil)
   675  	require.NoError(t, err)
   676  	assert.NotContains(t, originURL, username+":", "clone 后 origin URL 不应残留凭证")
   677  
   678  	// 2) 配置 author,避免依赖远端默认配置
   679  	_, err = e.git.ConfigureUser(e.ctx, "Sandbox Integration", "sandbox-it@example.com",
   680  		&ConfigOptions{Scope: GitConfigScopeLocal, Path: work})
   681  	require.NoError(t, err)
   682  
   683  	// 3) 在唯一分支上 commit + push
   684  	_, err = e.git.CreateBranch(e.ctx, work, branch, nil)
   685  	require.NoError(t, err)
   686  	_, err = e.sb.Files().Write(e.ctx, work+"/"+markerFile, []byte(marker))
   687  	require.NoError(t, err)
   688  	_, err = e.git.Add(e.ctx, work, nil)
   689  	require.NoError(t, err)
   690  	_, err = e.git.Commit(e.ctx, work, "test: sandbox integration "+branch, nil)
   691  	require.NoError(t, err)
   692  
   693  	// 4) cleanup:无论后面成功失败都尝试删除远端分支
   694  	t.Cleanup(func() {
   695  		cleanupCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
   696  		defer cancel()
   697  		// 用 shell 直接 push --delete,避免再走一遍 SDK 的凭证注入路径
   698  		authedURL, werr := withCredentials(repoURL, username, password)
   699  		if werr != nil {
   700  			t.Logf("cleanup withCredentials 失败: %v", werr)
   701  			return
   702  		}
   703  		cmd := fmt.Sprintf("git -C %s push %s --delete %s",
   704  			shellEscape(work), shellEscape(authedURL), shellEscape(branch))
   705  		res, err := e.sb.Commands().Run(cleanupCtx, cmd,
   706  			WithEnvs(map[string]string{"GIT_TERMINAL_PROMPT": "0"}))
   707  		if err != nil || (res != nil && res.ExitCode != 0) {
   708  			t.Logf("cleanup 删除远端分支 %s 失败(可能仓库已无该分支): err=%v exit=%v stderr=%q",
   709  				branch, err, res.ExitCode, res.Stderr)
   710  		} else {
   711  			t.Logf("已删除远端临时分支 %s", branch)
   712  		}
   713  	})
   714  
   715  	_, err = e.git.Push(e.ctx, work, &PushOptions{
   716  		Remote: "origin", Branch: branch,
   717  		Username: username, Password: password,
   718  	})
   719  	require.NoError(t, err, "Push 失败 —— 请确认 token 具备目标仓库的 push 权限")
   720  
   721  	// Push 之后 origin URL 仍不应包含凭证
   722  	originURL, err = e.git.RemoteGet(e.ctx, work, "origin", nil)
   723  	require.NoError(t, err)
   724  	assert.NotContains(t, originURL, username+":", "Push 完成后 origin URL 不应残留凭证")
   725  	assert.NotContains(t, originURL, password)
   726  
   727  	// 5) 用一个全新的仓库 Clone + Pull,验证 push 上去的提交真的可被远端读到
   728  	_, err = e.git.Clone(e.ctx, repoURL, &CloneOptions{
   729  		Path: verify, Branch: branch, Depth: 1,
   730  		Username: username, Password: password,
   731  	})
   732  	require.NoError(t, err, "Clone --branch %s 失败 —— Push 可能没真正生效", branch)
   733  
   734  	exists, err := e.sb.Files().Exists(e.ctx, verify+"/"+markerFile)
   735  	require.NoError(t, err)
   736  	assert.True(t, exists, "新 clone 出来的仓库应能看到 push 上去的 %s", markerFile)
   737  	got, err := e.sb.Files().ReadText(e.ctx, verify+"/"+markerFile)
   738  	require.NoError(t, err)
   739  	assert.Equal(t, marker, got)
   740  
   741  	// 6) 在 work 上再追加一次提交并 push,然后在 verify 仓库 Pull,验证 Pull 拉到新提交
   742  	follow := "follow-up\n"
   743  	followFile := branch + "-2.txt"
   744  	_, err = e.sb.Files().Write(e.ctx, work+"/"+followFile, []byte(follow))
   745  	require.NoError(t, err)
   746  	_, err = e.git.Add(e.ctx, work, nil)
   747  	require.NoError(t, err)
   748  	_, err = e.git.Commit(e.ctx, work, "test: follow-up "+branch, nil)
   749  	require.NoError(t, err)
   750  	_, err = e.git.Push(e.ctx, work, &PushOptions{
   751  		Remote: "origin", Branch: branch,
   752  		Username: username, Password: password,
   753  	})
   754  	require.NoError(t, err)
   755  
   756  	_, err = e.git.Pull(e.ctx, verify, &PullOptions{
   757  		Remote: "origin", Branch: branch,
   758  		Username: username, Password: password,
   759  	})
   760  	require.NoError(t, err)
   761  	exists, err = e.sb.Files().Exists(e.ctx, verify+"/"+followFile)
   762  	require.NoError(t, err)
   763  	assert.True(t, exists, "Pull 之后应能看到 follow-up 文件 %s", followFile)
   764  }
   765  
   766  // stagedNames 返回 status 中处于 staged 状态的文件名集合。
   767  func stagedNames(s *GitStatus) []string {
   768  	var out []string
   769  	for _, f := range s.FileStatus {
   770  		if f.Staged {
   771  			out = append(out, f.Name)
   772  		}
   773  	}
   774  	return out
   775  }
   776  
   777  // getGitCredsFromEnv 从环境变量读取 git 凭证;缺失时跳过当前测试。
   778  func getGitCredsFromEnv(t *testing.T) (string, string, string) {
   779  	t.Helper()
   780  	repoURL := strings.TrimSpace(os.Getenv("QINIU_GIT_REPO_URL"))
   781  	username := strings.TrimSpace(os.Getenv("QINIU_GIT_USERNAME"))
   782  	password := strings.TrimSpace(os.Getenv("QINIU_GIT_PASSWORD"))
   783  	if repoURL == "" || username == "" || password == "" {
   784  		t.Skip("未设置 QINIU_GIT_REPO_URL / QINIU_GIT_USERNAME / QINIU_GIT_PASSWORD,跳过带凭证的 Clone 测试")
   785  	}
   786  	return repoURL, username, password
   787  }