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 }