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  }