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 }