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

     1  package sandbox
     2  
     3  import (
     4  	"net/url"
     5  	"path"
     6  	"strconv"
     7  	"strings"
     8  )
     9  
    10  // deriveRepoDirFromURL 从 git URL 推导默认的仓库目录名(去除 .git 后缀)。
    11  func deriveRepoDirFromURL(rawURL string) string {
    12  	u, err := url.Parse(rawURL)
    13  	var name string
    14  	if err == nil && u.Path != "" {
    15  		name = path.Base(u.Path)
    16  	} else {
    17  		// 处理形如 git@host:owner/repo.git 的 SCP 风格地址。
    18  		if idx := strings.LastIndex(rawURL, ":"); idx >= 0 {
    19  			name = path.Base(rawURL[idx+1:])
    20  		} else {
    21  			name = path.Base(rawURL)
    22  		}
    23  	}
    24  	name = strings.TrimSuffix(name, ".git")
    25  	if name == "." || name == "/" || name == "" {
    26  		return ""
    27  	}
    28  	return name
    29  }
    30  
    31  // parseGitStatus 解析 `git status --porcelain=1 -b` 的输出。
    32  func parseGitStatus(out string) *GitStatus {
    33  	status := &GitStatus{}
    34  	lines := strings.Split(out, "\n")
    35  	for _, line := range lines {
    36  		if line == "" {
    37  			continue
    38  		}
    39  		if strings.HasPrefix(line, "## ") {
    40  			parseBranchLine(line[3:], status)
    41  			continue
    42  		}
    43  		entry, ok := parseFileStatusLine(line)
    44  		if ok {
    45  			status.FileStatus = append(status.FileStatus, entry)
    46  		}
    47  	}
    48  	return status
    49  }
    50  
    51  // parseBranchLine 解析 porcelain 输出首行的分支信息。
    52  //
    53  // 需处理 git 在多种状态下的输出形态:
    54  //   - "main...origin/main [ahead 1, behind 2]" 普通跟踪分支
    55  //   - "HEAD (no branch)"                       传统 detached HEAD(如 rebase 中)
    56  //   - "HEAD (detached at <ref>)"               显式 detached HEAD
    57  //   - "No commits yet on main"                 unborn 分支(仓库未首次提交)
    58  //   - "Initial commit on main"                 旧版 git 的 unborn 写法
    59  //
    60  // 不能简单按第一个空格切分,否则 "No commits yet on main" 会把 "No" 当成分支名。
    61  func parseBranchLine(line string, status *GitStatus) {
    62  	if line == "" {
    63  		return
    64  	}
    65  
    66  	// 先剥离尾部的 "[ahead N, behind M]"
    67  	branchPart := line
    68  	rest := ""
    69  	if i := strings.Index(line, " ["); i >= 0 && strings.HasSuffix(line, "]") {
    70  		branchPart = line[:i]
    71  		rest = line[i+2 : len(line)-1]
    72  	}
    73  
    74  	// 处理 detached HEAD 的两种形态。
    75  	if strings.HasPrefix(branchPart, "HEAD (no branch)") ||
    76  		strings.HasPrefix(branchPart, "HEAD (detached") ||
    77  		strings.HasPrefix(branchPart, "(no branch)") {
    78  		status.Detached = true
    79  		parseAheadBehind(rest, status)
    80  		return
    81  	}
    82  
    83  	// 处理 unborn 分支:尚未首次提交时 git 会输出 "No commits yet on <branch>"
    84  	// 或更早版本的 "Initial commit on <branch>"。此时仍记录分支名,但没有 upstream。
    85  	switch {
    86  	case strings.HasPrefix(branchPart, "No commits yet on "):
    87  		status.CurrentBranch = strings.TrimPrefix(branchPart, "No commits yet on ")
    88  	case strings.HasPrefix(branchPart, "Initial commit on "):
    89  		status.CurrentBranch = strings.TrimPrefix(branchPart, "Initial commit on ")
    90  	default:
    91  		if before, after, ok := strings.Cut(branchPart, "..."); ok {
    92  			status.CurrentBranch = before
    93  			status.Upstream = after
    94  		} else {
    95  			status.CurrentBranch = branchPart
    96  		}
    97  	}
    98  
    99  	parseAheadBehind(rest, status)
   100  }
   101  
   102  // parseAheadBehind 解析 ahead/behind 段(不含外层方括号)。
   103  func parseAheadBehind(rest string, status *GitStatus) {
   104  	if rest == "" {
   105  		return
   106  	}
   107  	for _, seg := range strings.Split(rest, ",") {
   108  		seg = strings.TrimSpace(seg)
   109  		switch {
   110  		case strings.HasPrefix(seg, "ahead "):
   111  			if n, err := strconv.Atoi(strings.TrimPrefix(seg, "ahead ")); err == nil {
   112  				status.Ahead = n
   113  			}
   114  		case strings.HasPrefix(seg, "behind "):
   115  			if n, err := strconv.Atoi(strings.TrimPrefix(seg, "behind ")); err == nil {
   116  				status.Behind = n
   117  			}
   118  		}
   119  	}
   120  }
   121  
   122  // parseFileStatusLine 解析 porcelain 输出的单条文件状态行。
   123  //
   124  // 当文件名包含空格、双引号或非可打印字符时,git 默认会输出 C-style 引号转义
   125  // 的路径(如 `?? "with space.txt"`、`R  "old name" -> "new name"`),这里需要
   126  // 对引号内容做反转义,确保调用方拿到的是真正的相对路径。
   127  func parseFileStatusLine(line string) (GitFileStatus, bool) {
   128  	if len(line) < 3 {
   129  		return GitFileStatus{}, false
   130  	}
   131  	x := string(line[0])
   132  	y := string(line[1])
   133  	rest := line[3:]
   134  
   135  	entry := GitFileStatus{
   136  		IndexStatus:       x,
   137  		WorkingTreeStatus: y,
   138  	}
   139  
   140  	// 重命名/复制:路径形如 "old -> new",两端可能各自被引号包裹。
   141  	if x == "R" || x == "C" || y == "R" || y == "C" {
   142  		if before, after, ok := splitRenamePath(rest); ok {
   143  			entry.RenamedFrom = unquoteCPath(before)
   144  			entry.Name = unquoteCPath(after)
   145  		} else {
   146  			entry.Name = unquoteCPath(rest)
   147  		}
   148  	} else {
   149  		entry.Name = unquoteCPath(rest)
   150  	}
   151  
   152  	entry.Status = normalizeFileStatus(x, y)
   153  	entry.Staged = isStaged(x, entry.Status)
   154  	return entry, true
   155  }
   156  
   157  // splitRenamePath 拆分 "old -> new" 形式的重命名路径,正确处理两端被引号包裹的情况。
   158  // 普通情况下直接按 " -> " 切分;若 old 部分以 `"` 开头,则定位到匹配的结束引号后再找 " -> "。
   159  func splitRenamePath(s string) (string, string, bool) {
   160  	if strings.HasPrefix(s, `"`) {
   161  		// 找到第一个未被反斜杠转义的结束引号。
   162  		i := 1
   163  		for i < len(s) {
   164  			if s[i] == '\\' && i+1 < len(s) {
   165  				i += 2
   166  				continue
   167  			}
   168  			if s[i] == '"' {
   169  				break
   170  			}
   171  			i++
   172  		}
   173  		if i >= len(s) {
   174  			return "", "", false
   175  		}
   176  		head := s[:i+1]
   177  		rest := s[i+1:]
   178  		const sep = " -> "
   179  		if strings.HasPrefix(rest, sep) {
   180  			return head, rest[len(sep):], true
   181  		}
   182  		return "", "", false
   183  	}
   184  	before, after, ok := strings.Cut(s, " -> ")
   185  	return before, after, ok
   186  }
   187  
   188  // unquoteCPath 对 git porcelain 输出的 C-style 引号路径做反转义。
   189  //
   190  // 输入未以双引号开头时原样返回;否则按 git 的 quote_c_style 规则解析:
   191  // 支持 \\、\"、\a \b \f \n \r \t \v 以及 \<3-octal> 任意字节。
   192  // 解析失败时回退到原字符串,避免抛错破坏调用流程。
   193  func unquoteCPath(s string) string {
   194  	if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' {
   195  		return s
   196  	}
   197  	body := s[1 : len(s)-1]
   198  	out := make([]byte, 0, len(body))
   199  	for i := 0; i < len(body); i++ {
   200  		c := body[i]
   201  		if c != '\\' {
   202  			out = append(out, c)
   203  			continue
   204  		}
   205  		if i+1 >= len(body) {
   206  			return s
   207  		}
   208  		next := body[i+1]
   209  		switch next {
   210  		case '\\', '"', '\'':
   211  			out = append(out, next)
   212  			i++
   213  		case 'a':
   214  			out = append(out, '\a')
   215  			i++
   216  		case 'b':
   217  			out = append(out, '\b')
   218  			i++
   219  		case 'f':
   220  			out = append(out, '\f')
   221  			i++
   222  		case 'n':
   223  			out = append(out, '\n')
   224  			i++
   225  		case 'r':
   226  			out = append(out, '\r')
   227  			i++
   228  		case 't':
   229  			out = append(out, '\t')
   230  			i++
   231  		case 'v':
   232  			out = append(out, '\v')
   233  			i++
   234  		default:
   235  			// 三位八进制:\NNN(git 用它表示非 ASCII 字节,例如 UTF-8 的多字节序列)。
   236  			// 三位 octal 最大值为 0o777=511,超过 byte 范围时 strconv.ParseUint 会报错;
   237  			// 此时按"非法转义"处理:保留字面 `\` 并从下一个字符继续解析,避免整段字符串作废。
   238  			if i+3 < len(body) && isOctal(body[i+1]) && isOctal(body[i+2]) && isOctal(body[i+3]) {
   239  				if v, perr := strconv.ParseUint(string(body[i+1:i+4]), 8, 8); perr == nil {
   240  					out = append(out, byte(v))
   241  					i += 3
   242  					continue
   243  				}
   244  			}
   245  			out = append(out, '\\')
   246  		}
   247  	}
   248  	return string(out)
   249  }
   250  
   251  // isOctal 判断字节是否为八进制数字字符。
   252  func isOctal(c byte) bool { return c >= '0' && c <= '7' }
   253  
   254  // normalizeFileStatus 将 XY 字符归一化为可读状态。
   255  func normalizeFileStatus(x, y string) string {
   256  	if x == "?" && y == "?" {
   257  		return "untracked"
   258  	}
   259  	if x == "!" && y == "!" {
   260  		return "ignored"
   261  	}
   262  	if isConflict(x, y) {
   263  		return "conflict"
   264  	}
   265  	// 优先看索引位,其次工作区位
   266  	if s := mapStatusChar(x); s != "" && s != "unmodified" {
   267  		return s
   268  	}
   269  	if s := mapStatusChar(y); s != "" && s != "unmodified" {
   270  		return s
   271  	}
   272  	return "unmodified"
   273  }
   274  
   275  // mapStatusChar 将单个状态字符映射为可读字符串。
   276  func mapStatusChar(c string) string {
   277  	switch c {
   278  	case " ":
   279  		return "unmodified"
   280  	case "M":
   281  		return "modified"
   282  	case "A":
   283  		return "added"
   284  	case "D":
   285  		return "deleted"
   286  	case "R":
   287  		return "renamed"
   288  	case "C":
   289  		return "copied"
   290  	case "T":
   291  		return "type-changed"
   292  	case "U":
   293  		return "conflict"
   294  	}
   295  	return ""
   296  }
   297  
   298  // isConflict 判断 XY 是否构成 git 合并冲突。
   299  // 参考 git status 文档中 "Unmerged" 表(DD/AU/UD/UA/DU/AA/UU)。
   300  func isConflict(x, y string) bool {
   301  	switch x + y {
   302  	case "DD", "AU", "UD", "UA", "DU", "AA", "UU":
   303  		return true
   304  	}
   305  	return x == "U" || y == "U"
   306  }
   307  
   308  // isStaged 判断条目是否处于已暂存状态。
   309  func isStaged(x, status string) bool {
   310  	if status == "untracked" || status == "ignored" || status == "conflict" {
   311  		return false
   312  	}
   313  	if x == " " || x == "?" || x == "!" {
   314  		return false
   315  	}
   316  	return true
   317  }
   318  
   319  // parseGitBranches 解析 `git branch --format=%(refname:short)\t%(HEAD)` 的输出。
   320  //
   321  // detached HEAD 时 git 会把 "(HEAD detached at <sha>)" / "(HEAD detached from <ref>)"
   322  // 作为一项输出并标记为当前 HEAD;这里跳过该伪分支以保持 CurrentBranch 在 HEAD 分离时为空。
   323  func parseGitBranches(out string) *GitBranches {
   324  	result := &GitBranches{}
   325  	for _, line := range strings.Split(out, "\n") {
   326  		line = strings.TrimSpace(line)
   327  		if line == "" {
   328  			continue
   329  		}
   330  		parts := strings.SplitN(line, "\t", 2)
   331  		name := strings.TrimSpace(parts[0])
   332  		if name == "" {
   333  			continue
   334  		}
   335  		isCurrent := len(parts) == 2 && strings.TrimSpace(parts[1]) == "*"
   336  		if strings.HasPrefix(name, "(HEAD detached") || name == "(no branch)" {
   337  			continue
   338  		}
   339  		result.Branches = append(result.Branches, name)
   340  		if isCurrent {
   341  			result.CurrentBranch = name
   342  		}
   343  	}
   344  	return result
   345  }