github.com/qiniu/go-sdk/v7@v7.27.0/sandbox/git_test.go (about) 1 //go:build unit 2 3 package sandbox 4 5 import ( 6 "errors" 7 "strings" 8 "testing" 9 10 "github.com/stretchr/testify/assert" 11 ) 12 13 func TestShellEscape(t *testing.T) { 14 cases := []struct { 15 in string 16 want string 17 }{ 18 {"simple", "'simple'"}, 19 {"a b", "'a b'"}, 20 {"it's", `'it'"'"'s'`}, 21 {"", "''"}, 22 } 23 for _, c := range cases { 24 assert.Equal(t, c.want, shellEscape(c.in), "input=%q", c.in) 25 } 26 } 27 28 func TestBuildGitCommand(t *testing.T) { 29 cmd := buildGitCommand([]string{"status", "--porcelain=1"}, "/repo path") 30 assert.Equal(t, `'git' '-C' '/repo path' 'status' '--porcelain=1'`, cmd) 31 32 cmd = buildGitCommand([]string{"init"}, "") 33 assert.Equal(t, `'git' 'init'`, cmd) 34 } 35 36 func TestBuildPushPullArgs(t *testing.T) { 37 assert.Equal(t, []string{"push", "--set-upstream", "origin", "main"}, 38 buildPushArgs("origin", "main", true)) 39 assert.Equal(t, []string{"push"}, buildPushArgs("", "", true)) 40 assert.Equal(t, []string{"push", "origin", "main"}, 41 buildPushArgs("origin", "main", false)) 42 43 assert.Equal(t, []string{"pull", "origin", "main"}, 44 buildPullArgs("origin", "main")) 45 assert.Equal(t, []string{"pull"}, buildPullArgs("", "")) 46 } 47 48 func TestBuildAddArgs(t *testing.T) { 49 assert.Equal(t, []string{"add", "."}, buildAddArgs(nil, false)) 50 assert.Equal(t, []string{"add", "-A"}, buildAddArgs(nil, true)) 51 assert.Equal(t, []string{"add", "--", "a.go", "b.go"}, buildAddArgs([]string{"a.go", "b.go"}, true)) 52 } 53 54 func TestBuildCommitArgs(t *testing.T) { 55 got := buildCommitArgs("msg", "Alice", "a@x.io", true) 56 assert.Equal(t, 57 []string{"-c", "user.name=Alice", "-c", "user.email=a@x.io", "commit", "-m", "msg", "--allow-empty"}, 58 got, 59 ) 60 assert.Equal(t, []string{"commit", "-m", "msg"}, buildCommitArgs("msg", "", "", false)) 61 } 62 63 func TestBuildResetArgs(t *testing.T) { 64 args, err := buildResetArgs(GitResetModeHard, "HEAD~1", nil) 65 assert.NoError(t, err) 66 assert.Equal(t, []string{"reset", "--hard", "HEAD~1"}, args) 67 68 args, err = buildResetArgs("", "", []string{"a.go"}) 69 assert.NoError(t, err) 70 assert.Equal(t, []string{"reset", "--", "a.go"}, args) 71 72 // mode 与 paths 同时给出会被拒绝(git reset 的两种用法互斥) 73 _, err = buildResetArgs(GitResetModeHard, "HEAD~1", []string{"a.go"}) 74 assert.Error(t, err) 75 var ie *InvalidArgumentError 76 assert.True(t, errors.As(err, &ie)) 77 78 _, err = buildResetArgs(GitResetMode("invalid"), "", nil) 79 assert.Error(t, err) 80 assert.True(t, errors.As(err, &ie)) 81 } 82 83 func TestBuildRestoreArgs(t *testing.T) { 84 // 默认仅恢复工作区 85 args, err := buildRestoreArgs([]string{"a.go"}, nil, nil, "") 86 assert.NoError(t, err) 87 assert.Equal(t, []string{"restore", "--worktree", "--", "a.go"}, args) 88 89 // staged=true 时默认不恢复工作区 90 tr := true 91 args, err = buildRestoreArgs([]string{"a.go"}, &tr, nil, "") 92 assert.NoError(t, err) 93 assert.Equal(t, []string{"restore", "--staged", "--", "a.go"}, args) 94 95 // 显式两者都开启 96 args, err = buildRestoreArgs([]string{"a.go"}, &tr, &tr, "HEAD") 97 assert.NoError(t, err) 98 assert.Equal(t, []string{"restore", "--worktree", "--staged", "--source", "HEAD", "--", "a.go"}, args) 99 100 // staged=false、worktree=nil → 退化到默认 worktree,而不是抛"必须有一个为 true"的错 101 fa := false 102 args, err = buildRestoreArgs([]string{"a.go"}, &fa, nil, "") 103 assert.NoError(t, err) 104 assert.Equal(t, []string{"restore", "--worktree", "--", "a.go"}, args) 105 106 // staged=nil、worktree=false → 同上退化到默认 worktree 107 args, err = buildRestoreArgs([]string{"a.go"}, nil, &fa, "") 108 assert.NoError(t, err) 109 assert.Equal(t, []string{"restore", "--worktree", "--", "a.go"}, args) 110 111 // 显式两者都为 false → 真正没有恢复目标,应报错 112 _, err = buildRestoreArgs([]string{"a.go"}, &fa, &fa, "") 113 assert.Error(t, err) 114 115 _, err = buildRestoreArgs(nil, nil, nil, "") 116 assert.Error(t, err) 117 } 118 119 func TestBuildClonePlan_NoCredentials(t *testing.T) { 120 plan, err := buildClonePlan("https://github.com/o/r.git", "/tmp/r", "main", 1, "", "", false) 121 assert.NoError(t, err) 122 assert.False(t, plan.shouldStrip) 123 assert.Equal(t, 124 []string{"clone", "https://github.com/o/r.git", "--branch", "main", "--single-branch", "--depth", "1", "/tmp/r"}, 125 plan.args, 126 ) 127 } 128 129 func TestBuildClonePlan_StripsCredentials(t *testing.T) { 130 plan, err := buildClonePlan("https://github.com/o/r.git", "", "", 0, "alice", "tk", false) 131 assert.NoError(t, err) 132 assert.True(t, plan.shouldStrip) 133 assert.Equal(t, "https://github.com/o/r.git", plan.sanitizedURL) 134 assert.Equal(t, "r", plan.repoPath) 135 // args[1] 应包含凭证 136 assert.Contains(t, plan.args[1], "alice:tk@") 137 } 138 139 func TestBuildClonePlan_StoresCredentials(t *testing.T) { 140 plan, err := buildClonePlan("https://github.com/o/r.git", "/tmp/r", "", 0, "alice", "tk", true) 141 assert.NoError(t, err) 142 assert.False(t, plan.shouldStrip) 143 } 144 145 // TestBuildClonePlan_RejectsInlineHTTPCredentials 验证调用方直接把凭证嵌入 URL 时也强制 https, 146 // 与 withCredentials 注入路径保持一致的安全边界。 147 func TestBuildClonePlan_RejectsInlineHTTPCredentials(t *testing.T) { 148 _, err := buildClonePlan("http://alice:tk@example.com/r.git", "/tmp/r", "", 0, "", "", false) 149 var ie *InvalidArgumentError 150 assert.True(t, errors.As(err, &ie), "http+inline 凭证应被拒,实际: %T %v", err, err) 151 152 // https + inline 凭证应允许通过,由后续 strip 处理 153 _, err = buildClonePlan("https://alice:tk@example.com/r.git", "/tmp/r", "", 0, "", "", false) 154 assert.NoError(t, err) 155 156 // 无凭证时不应触发校验,例如 SCP / file 路径 157 _, err = buildClonePlan("git@github.com:o/r.git", "/tmp/r", "", 0, "", "", false) 158 assert.NoError(t, err) 159 } 160 161 func TestWithCredentials(t *testing.T) { 162 got, err := withCredentials("https://github.com/o/r.git", "alice", "tk:1") 163 assert.NoError(t, err) 164 assert.Equal(t, "https://alice:tk%3A1@github.com/o/r.git", got) 165 166 // 仅一个不为空 -> 错 167 _, err = withCredentials("https://github.com/o/r.git", "alice", "") 168 assert.Error(t, err) 169 170 // 非 http(s) -> 错 171 _, err = withCredentials("git@github.com:o/r.git", "alice", "tk") 172 assert.Error(t, err) 173 174 // http -> 错(避免凭证暴露在明文链路) 175 _, err = withCredentials("http://github.com/o/r.git", "alice", "tk") 176 assert.Error(t, err) 177 178 // 都为空 -> 原样返回 179 got, err = withCredentials("https://github.com/o/r.git", "", "") 180 assert.NoError(t, err) 181 assert.Equal(t, "https://github.com/o/r.git", got) 182 } 183 184 func TestStripCredentials(t *testing.T) { 185 assert.Equal(t, "https://github.com/o/r.git", 186 stripCredentials("https://alice:tk@github.com/o/r.git")) 187 assert.Equal(t, "https://github.com/o/r.git", 188 stripCredentials("https://github.com/o/r.git")) 189 assert.Equal(t, "git@github.com:o/r.git", 190 stripCredentials("git@github.com:o/r.git")) 191 } 192 193 func TestDeriveRepoDirFromURL(t *testing.T) { 194 cases := map[string]string{ 195 "https://github.com/o/r.git": "r", 196 "https://github.com/o/r": "r", 197 "git@github.com:o/r.git": "r", 198 "https://github.com/o/r.git?ref=v1": "r", 199 } 200 for in, want := range cases { 201 assert.Equal(t, want, deriveRepoDirFromURL(in), "input=%s", in) 202 } 203 } 204 205 func TestParseGitStatus_BranchHeader(t *testing.T) { 206 out := "## main...origin/main [ahead 1, behind 2]\n" 207 s := parseGitStatus(out) 208 assert.Equal(t, "main", s.CurrentBranch) 209 assert.Equal(t, "origin/main", s.Upstream) 210 assert.Equal(t, 1, s.Ahead) 211 assert.Equal(t, 2, s.Behind) 212 assert.False(t, s.Detached) 213 assert.True(t, s.IsClean()) 214 } 215 216 func TestParseGitStatus_DetachedAndFiles(t *testing.T) { 217 out := strings.Join([]string{ 218 "## HEAD (no branch)", 219 "M staged.go", 220 " M dirty.go", 221 "?? new.go", 222 "R old.go -> renamed.go", 223 "UU conflict.go", 224 "", 225 }, "\n") 226 s := parseGitStatus(out) 227 assert.True(t, s.Detached) 228 assert.Equal(t, 5, s.TotalCount()) 229 assert.Equal(t, 2, s.StagedCount()) // staged.go + renamed.go 230 assert.True(t, s.HasUntracked()) 231 assert.True(t, s.HasConflicts()) 232 233 byName := map[string]GitFileStatus{} 234 for _, f := range s.FileStatus { 235 byName[f.Name] = f 236 } 237 assert.Equal(t, "modified", byName["staged.go"].Status) 238 assert.True(t, byName["staged.go"].Staged) 239 assert.Equal(t, "modified", byName["dirty.go"].Status) 240 assert.False(t, byName["dirty.go"].Staged) 241 assert.Equal(t, "untracked", byName["new.go"].Status) 242 assert.Equal(t, "renamed", byName["renamed.go"].Status) 243 assert.Equal(t, "old.go", byName["renamed.go"].RenamedFrom) 244 assert.Equal(t, "conflict", byName["conflict.go"].Status) 245 } 246 247 func TestParseGitStatus_DetachedAtRef(t *testing.T) { 248 s := parseGitStatus("## HEAD (detached at v1.2.3)\n") 249 assert.True(t, s.Detached) 250 assert.Empty(t, s.CurrentBranch) 251 assert.Empty(t, s.Upstream) 252 } 253 254 func TestParseGitStatus_UnbornBranch(t *testing.T) { 255 // "git status --porcelain=1 -b" 在仓库尚未首次提交时输出 "## No commits yet on <branch>" 256 s := parseGitStatus("## No commits yet on main\n?? new.go\n") 257 assert.False(t, s.Detached) 258 assert.Equal(t, "main", s.CurrentBranch) 259 assert.Empty(t, s.Upstream) 260 assert.Equal(t, 1, s.UntrackedCount()) 261 262 // 旧版 git 用 "Initial commit on" 措辞 263 s = parseGitStatus("## Initial commit on master\n") 264 assert.Equal(t, "master", s.CurrentBranch) 265 assert.False(t, s.Detached) 266 } 267 268 func TestParseGitStatus_AheadBehindOnly(t *testing.T) { 269 s := parseGitStatus("## main...origin/main [ahead 3]\n") 270 assert.Equal(t, "main", s.CurrentBranch) 271 assert.Equal(t, 3, s.Ahead) 272 assert.Equal(t, 0, s.Behind) 273 } 274 275 func TestParseGitBranches(t *testing.T) { 276 out := "main\t*\nfeature\t \nrelease\t\n" 277 b := parseGitBranches(out) 278 assert.Equal(t, []string{"main", "feature", "release"}, b.Branches) 279 assert.Equal(t, "main", b.CurrentBranch) 280 } 281 282 func TestParseGitBranches_DetachedHEAD(t *testing.T) { 283 // detached HEAD 时 git 会输出 "(HEAD detached at <sha>)" 并标记为当前; 284 // 该伪分支不应进入 Branches,CurrentBranch 也应保持为空。 285 out := "(HEAD detached at 1234abc)\t*\nmain\t \nfeature\t \n" 286 b := parseGitBranches(out) 287 assert.Equal(t, []string{"main", "feature"}, b.Branches) 288 assert.Empty(t, b.CurrentBranch) 289 } 290 291 func TestParseGitStatus_QuotedPaths(t *testing.T) { 292 // porcelain v1 在文件名含空格、引号、非 ASCII 时会输出 C-style 引号路径。 293 out := strings.Join([]string{ 294 `?? "with space.txt"`, 295 ` M "quote\"name.txt"`, 296 `R "old name.txt" -> "new name.txt"`, 297 `A "tab\there.txt"`, 298 "", 299 }, "\n") 300 s := parseGitStatus(out) 301 byName := map[string]GitFileStatus{} 302 for _, f := range s.FileStatus { 303 byName[f.Name] = f 304 } 305 assert.Contains(t, byName, "with space.txt") 306 assert.Contains(t, byName, `quote"name.txt`) 307 assert.Contains(t, byName, "new name.txt") 308 assert.Equal(t, "old name.txt", byName["new name.txt"].RenamedFrom) 309 assert.Contains(t, byName, "tab\there.txt") 310 } 311 312 func TestUnquoteCPath(t *testing.T) { 313 cases := map[string]string{ 314 `plain.txt`: `plain.txt`, 315 `"with space.txt"`: `with space.txt`, 316 `"a\"b"`: `a"b`, 317 `"a\\b"`: `a\b`, 318 `"tab\there"`: "tab\there", 319 `"newline\nhere"`: "newline\nhere", 320 `"unicode\303\251"`: "unicode\xc3\xa9", // é (UTF-8) 321 } 322 for in, want := range cases { 323 assert.Equal(t, want, unquoteCPath(in), "input=%q", in) 324 } 325 } 326 327 func TestUnstagedCount_MMCountedOnce(t *testing.T) { 328 // "MM file" 表示同一文件既有 staged 又有 unstaged 改动,应同时计入两边。 329 s := parseGitStatus("## main\nMM file.go\n M dirty.go\n?? new.go\n") 330 assert.Equal(t, 1, s.StagedCount()) 331 assert.Equal(t, 2, s.UnstagedCount()) 332 assert.Equal(t, 1, s.UntrackedCount()) 333 } 334 335 func TestIsAuthFailure(t *testing.T) { 336 err := &gitCommandError{Cmd: "push", Result: &CommandResult{ExitCode: 128, Stderr: "fatal: Authentication failed for foo"}} 337 assert.True(t, isAuthFailure(err)) 338 assert.False(t, isMissingUpstream(err)) 339 340 err = &gitCommandError{Cmd: "pull", Result: &CommandResult{Stderr: "There is no tracking information for the current branch"}} 341 assert.True(t, isMissingUpstream(err)) 342 assert.False(t, isAuthFailure(err)) 343 344 // 工作树/锁文件等通用的 "Permission denied" 不应被误判为认证失败 345 err = &gitCommandError{Cmd: "clone", Result: &CommandResult{ExitCode: 128, Stderr: "fatal: could not create work tree dir 'foo': Permission denied"}} 346 assert.False(t, isAuthFailure(err)) 347 348 assert.False(t, isAuthFailure(errors.New("other"))) 349 } 350 351 func TestResolveConfigScope(t *testing.T) { 352 flag, p, err := resolveConfigScope("", "") 353 assert.NoError(t, err) 354 assert.Equal(t, "--global", flag) 355 assert.Empty(t, p) 356 357 flag, p, err = resolveConfigScope(GitConfigScopeLocal, "/repo") 358 assert.NoError(t, err) 359 assert.Equal(t, "--local", flag) 360 assert.Equal(t, "/repo", p) 361 362 _, _, err = resolveConfigScope(GitConfigScopeLocal, "") 363 assert.Error(t, err) 364 365 _, _, err = resolveConfigScope("weird", "") 366 assert.Error(t, err) 367 } 368 369 func TestBuildAuthErrorMessage(t *testing.T) { 370 assert.Contains(t, buildAuthErrorMessage("clone", true), "password/token") 371 assert.Contains(t, buildAuthErrorMessage("push", false), "credentials") 372 assert.Contains(t, buildUpstreamErrorMessage("push"), "upstream") 373 assert.Contains(t, buildUpstreamErrorMessage("pull"), "upstream") 374 }