github.com/qiniu/go-sdk/v7@v7.27.0/sandbox/git.go (about) 1 // Package sandbox 中的 git.go 提供沙箱内 git 操作的高层封装。 2 // 3 // Git 操作通过 Commands.Run 调用沙箱内已预装的 git 二进制实现, 4 // 仅支持 HTTPS + username/password (token) 形式的认证,不支持 SSH key。 5 6 package sandbox 7 8 import ( 9 "context" 10 "errors" 11 "fmt" 12 "maps" 13 "strings" 14 "time" 15 ) 16 17 // defaultGitEnv 是所有 git 命令默认注入的环境变量,用于禁止交互式提示。 18 var defaultGitEnv = map[string]string{ 19 "GIT_TERMINAL_PROMPT": "0", 20 } 21 22 // credentialCleanupTimeout 是凭证清理(strip / 恢复 origin URL)使用的兜底超时。 23 // 这些步骤刻意脱离主 ctx,确保主 ctx 取消后也能执行;但需要一个有限时间, 24 // 避免沙箱响应慢或网络异常时调用方协程被永久卡住。 25 const credentialCleanupTimeout = 10 * time.Second 26 27 // Git 提供沙箱内的 git 操作接口。 28 // 29 // 仅支持 HTTPS + username/password (token) 认证,不支持 SSH key。 30 // 所有命令都会强制注入 GIT_TERMINAL_PROMPT=0 以禁止交互式输入。 31 type Git struct { 32 commands *Commands 33 } 34 35 // newGit 创建 Git 实例。 36 func newGit(c *Commands) *Git { 37 return &Git{commands: c} 38 } 39 40 // validateCredentialPair 校验 username 与 password 必须同时提供或同时为空。 41 // verb 用于拼装错误消息("clone" / "push" / "pull")。 42 func validateCredentialPair(verb, username, password string) error { 43 if (username == "") == (password == "") { 44 return nil 45 } 46 return &InvalidArgumentError{Msg: fmt.Sprintf("Username and password must be provided together for git %s, or not at all.", verb)} 47 } 48 49 // Clone 克隆远程仓库到沙箱中。 50 // 当 opts.Username/Password 提供且 opts.DangerouslyStoreCredentials 为 false 时, 51 // SDK 会在 clone 完成后通过 git remote set-url 移除 origin URL 中的凭证。 52 func (g *Git) Clone(ctx context.Context, repoURL string, opts *CloneOptions) (*CommandResult, error) { 53 if opts == nil { 54 opts = &CloneOptions{} 55 } 56 if err := validateCredentialPair("clone", opts.Username, opts.Password); err != nil { 57 return nil, err 58 } 59 60 plan, err := buildClonePlan(repoURL, opts.Path, opts.Branch, opts.Depth, opts.Username, opts.Password, opts.DangerouslyStoreCredentials) 61 if err != nil { 62 return nil, err 63 } 64 65 result, err := g.runGit(ctx, "clone", plan.args, "", &opts.GitOptions) 66 if err != nil { 67 if isAuthFailure(err) { 68 return nil, &GitAuthError{Msg: buildAuthErrorMessage("clone", opts.Username != "" && opts.Password == ""), Err: err} 69 } 70 return nil, err 71 } 72 73 if plan.shouldStrip { 74 // 即使 ctx 已取消也要清理凭证,避免带凭证的 URL 残留在 .git/config。 75 // 用独立的带超时 ctx,防止沙箱异常时无限阻塞。 76 cleanupCtx, cancel := context.WithTimeout(context.Background(), credentialCleanupTimeout) 77 defer cancel() 78 _, serr := g.runGit(cleanupCtx, "remote", buildRemoteSetURLArgs("origin", plan.sanitizedURL), plan.repoPath, &opts.GitOptions) 79 if serr != nil { 80 return result, fmt.Errorf("clone succeeded but failed to strip credentials: %w", serr) 81 } 82 } 83 return result, nil 84 } 85 86 // Init 在指定路径初始化新的 git 仓库。 87 func (g *Git) Init(ctx context.Context, path string, opts *InitOptions) (*CommandResult, error) { 88 if opts == nil { 89 opts = &InitOptions{} 90 } 91 args := []string{"init"} 92 if opts.InitialBranch != "" { 93 args = append(args, "--initial-branch", opts.InitialBranch) 94 } 95 if opts.Bare { 96 args = append(args, "--bare") 97 } 98 args = append(args, path) 99 return g.runGit(ctx, "init", args, "", &opts.GitOptions) 100 } 101 102 // Status 返回仓库状态信息。 103 func (g *Git) Status(ctx context.Context, path string, opts *GitOptions) (*GitStatus, error) { 104 result, err := g.runGit(ctx, "status", buildStatusArgs(), path, opts) 105 if err != nil { 106 return nil, err 107 } 108 return parseGitStatus(result.Stdout), nil 109 } 110 111 // Branches 返回仓库的分支列表。 112 // 113 // unborn 仓库(git init 后尚未提交)下 `git branch` 无任何输出,但 HEAD 已经 114 // 指向初始分支;此时退化到 `git symbolic-ref --short HEAD` 取出 CurrentBranch, 115 // 与 GitBranches.CurrentBranch "仅在 detached HEAD 时为空" 的契约保持一致。 116 func (g *Git) Branches(ctx context.Context, path string, opts *GitOptions) (*GitBranches, error) { 117 result, err := g.runGit(ctx, "branch", buildBranchesArgs(), path, opts) 118 if err != nil { 119 return nil, err 120 } 121 branches := parseGitBranches(result.Stdout) 122 if branches.CurrentBranch == "" && len(branches.Branches) == 0 { 123 if name, ok := g.unbornCurrentBranch(ctx, path, opts); ok { 124 branches.CurrentBranch = name 125 } 126 } 127 return branches, nil 128 } 129 130 // unbornCurrentBranch 尝试通过 symbolic-ref 读取 unborn 仓库的当前分支名。 131 // 仓库为 detached HEAD 或非 git 目录时 symbolic-ref 失败,返回 ok=false。 132 func (g *Git) unbornCurrentBranch(ctx context.Context, path string, opts *GitOptions) (string, bool) { 133 result, err := g.runGit(ctx, "symbolic-ref", []string{"symbolic-ref", "--short", "HEAD"}, path, opts) 134 if err != nil { 135 return "", false 136 } 137 name := strings.TrimSpace(result.Stdout) 138 if name == "" { 139 return "", false 140 } 141 return name, true 142 } 143 144 // CreateBranch 创建并切换到新分支。 145 func (g *Git) CreateBranch(ctx context.Context, path, branch string, opts *GitOptions) (*CommandResult, error) { 146 if branch == "" { 147 return nil, &InvalidArgumentError{Msg: "Branch name is required."} 148 } 149 return g.runGit(ctx, "checkout", buildCreateBranchArgs(branch), path, opts) 150 } 151 152 // CheckoutBranch 切换到已存在的分支。 153 func (g *Git) CheckoutBranch(ctx context.Context, path, branch string, opts *GitOptions) (*CommandResult, error) { 154 if branch == "" { 155 return nil, &InvalidArgumentError{Msg: "Branch name is required."} 156 } 157 return g.runGit(ctx, "checkout", buildCheckoutBranchArgs(branch), path, opts) 158 } 159 160 // DeleteBranch 删除分支。 161 func (g *Git) DeleteBranch(ctx context.Context, path, branch string, opts *DeleteBranchOptions) (*CommandResult, error) { 162 if branch == "" { 163 return nil, &InvalidArgumentError{Msg: "Branch name is required."} 164 } 165 if opts == nil { 166 opts = &DeleteBranchOptions{} 167 } 168 return g.runGit(ctx, "branch", buildDeleteBranchArgs(branch, opts.Force), path, &opts.GitOptions) 169 } 170 171 // Add 暂存指定文件,未指定文件时默认 stage 全部变更。 172 func (g *Git) Add(ctx context.Context, path string, opts *AddOptions) (*CommandResult, error) { 173 if opts == nil { 174 opts = &AddOptions{} 175 } 176 all := true 177 if opts.All != nil { 178 all = *opts.All 179 } 180 return g.runGit(ctx, "add", buildAddArgs(opts.Files, all), path, &opts.GitOptions) 181 } 182 183 // Commit 在仓库中创建一次提交。 184 func (g *Git) Commit(ctx context.Context, path, message string, opts *CommitOptions) (*CommandResult, error) { 185 if message == "" { 186 return nil, &InvalidArgumentError{Msg: "Commit message is required."} 187 } 188 if opts == nil { 189 opts = &CommitOptions{} 190 } 191 return g.runGit(ctx, "commit", buildCommitArgs(message, opts.AuthorName, opts.AuthorEmail, opts.AllowEmpty), path, &opts.GitOptions) 192 } 193 194 // Reset 重置 HEAD 到指定状态。 195 func (g *Git) Reset(ctx context.Context, path string, opts *ResetOptions) (*CommandResult, error) { 196 if opts == nil { 197 opts = &ResetOptions{} 198 } 199 args, err := buildResetArgs(opts.Mode, opts.Target, opts.Paths) 200 if err != nil { 201 return nil, err 202 } 203 return g.runGit(ctx, "reset", args, path, &opts.GitOptions) 204 } 205 206 // Restore 恢复工作区文件或取消暂存。 207 func (g *Git) Restore(ctx context.Context, path string, opts *RestoreOptions) (*CommandResult, error) { 208 if opts == nil { 209 return nil, &InvalidArgumentError{Msg: "Restore options are required."} 210 } 211 args, err := buildRestoreArgs(opts.Paths, opts.Staged, opts.Worktree, opts.Source) 212 if err != nil { 213 return nil, err 214 } 215 return g.runGit(ctx, "restore", args, path, &opts.GitOptions) 216 } 217 218 // Push 将提交推送到远程。 219 // 当 opts.Username/Password 提供时,SDK 会临时把凭证写入目标 remote URL,命令完成后立即恢复原 URL。 220 // 221 // 当未显式指定 Remote 时,SDK 会尝试自动选中仓库唯一的 remote(与"自动选择单一 remote" 222 // 契约一致);多 remote / 无 remote 时回退到 git 原生行为或返回带凭证场景下的明确错误。 223 // 224 // 当 SetUpstream=true 且未显式给 Branch 时,仅在 SDK 已确定 target remote 的前提下, 225 // 才会通过 git rev-parse 取出当前分支名再拼到 push 命令上(避免 `git push --set-upstream <remote>` 226 // 不带分支名被 git 拒绝)。target 未确定(多 remote / 无 remote)时保持原始 `git push` 形态, 227 // 让 git 自行报错或走默认语义,避免把分支名误当成 remote 名。 228 func (g *Git) Push(ctx context.Context, path string, opts *PushOptions) (*CommandResult, error) { 229 if opts == nil { 230 opts = &PushOptions{} 231 } 232 if err := validateCredentialPair("push", opts.Username, opts.Password); err != nil { 233 return nil, err 234 } 235 setUpstream := true 236 if opts.SetUpstream != nil { 237 setUpstream = *opts.SetUpstream 238 } 239 240 buildArgs := func(target string) ([]string, error) { 241 branch := opts.Branch 242 if target == "" { 243 if branch != "" { 244 return nil, &InvalidArgumentError{Msg: "Remote is required when Branch is specified and the repository does not have a single remote to auto-select."} 245 } 246 return buildPushArgs("", "", false), nil 247 } 248 if setUpstream && branch == "" { 249 name, err := g.currentBranch(ctx, path, &opts.GitOptions) 250 if err != nil { 251 return nil, err 252 } 253 branch = name 254 } 255 return buildPushArgs(target, branch, setUpstream), nil 256 } 257 return g.runWithOptionalCredentials(ctx, "push", path, opts.Remote, opts.Username, opts.Password, &opts.GitOptions, buildArgs) 258 } 259 260 // Pull 从远程拉取变更。 261 // 当 opts.Username/Password 提供时,SDK 会临时把凭证写入目标 remote URL,命令完成后立即恢复原 URL。 262 // 263 // 当未显式指定 Remote 时,SDK 会尝试自动选中仓库唯一的 remote(与"自动选择单一 remote" 264 // 契约一致)。当 Remote 与 Branch 都未指定时,SDK 会先检查当前分支是否配置了 upstream, 265 // 未配置则直接返回 GitUpstreamError 而不是把模糊错误抛给调用方。 266 func (g *Git) Pull(ctx context.Context, path string, opts *PullOptions) (*CommandResult, error) { 267 if opts == nil { 268 opts = &PullOptions{} 269 } 270 if err := validateCredentialPair("pull", opts.Username, opts.Password); err != nil { 271 return nil, err 272 } 273 274 if opts.Remote == "" && opts.Branch == "" { 275 ok, err := g.hasUpstream(ctx, path, &opts.GitOptions) 276 if err != nil { 277 return nil, err 278 } 279 if !ok { 280 return nil, &GitUpstreamError{Msg: buildUpstreamErrorMessage("pull")} 281 } 282 } 283 return g.runWithOptionalCredentials(ctx, "pull", path, opts.Remote, opts.Username, opts.Password, &opts.GitOptions, 284 func(target string) ([]string, error) { 285 // target 为空但显式给了 Branch 时直接返回错误: 286 // `git pull <branch>` 在 git 里会被解析成 `git pull <repository>`,与对外语义不一致。 287 if target == "" && opts.Branch != "" { 288 return nil, &InvalidArgumentError{Msg: "Remote is required when Branch is specified and the repository does not have a single remote to auto-select."} 289 } 290 return buildPullArgs(target, opts.Branch), nil 291 }) 292 } 293 294 // currentBranch 通过 git rev-parse 取出当前 HEAD 指向的分支名。 295 // detached HEAD 时 rev-parse 返回 "HEAD",会作为错误向上抛出,避免错误地把字面 "HEAD" 拼到 push 命令上。 296 func (g *Git) currentBranch(ctx context.Context, path string, opts *GitOptions) (string, error) { 297 result, err := g.runGit(ctx, "rev-parse", []string{"rev-parse", "--abbrev-ref", "HEAD"}, path, opts) 298 if err != nil { 299 return "", err 300 } 301 name := strings.TrimSpace(result.Stdout) 302 if name == "" || name == "HEAD" { 303 return "", &InvalidArgumentError{Msg: "Cannot push with SetUpstream=true on a detached HEAD; specify Branch explicitly or set SetUpstream=false."} 304 } 305 return name, nil 306 } 307 308 // runWithOptionalCredentials 执行 push/pull 等需要可选凭证注入的远程同步命令。 309 // buildArgs 接收已解析的 target remote 名(可为空),返回最终参数列表。 310 // 311 // remote 解析提到凭证分支之外,确保"带凭证"与"不带凭证"两种调用对单 remote 仓库 312 // 有一致的语义:当调用方未显式指定 remote 时,仓库恰好只有一个 remote 会被自动选中。 313 func (g *Git) runWithOptionalCredentials( 314 ctx context.Context, 315 sub, path, remote, username, password string, 316 opts *GitOptions, 317 buildArgs func(target string) ([]string, error), 318 ) (*CommandResult, error) { 319 withCreds := username != "" && password != "" 320 321 // 不带凭证时,对显式 remote 直接使用;未指定 remote 时尝试自动选中唯一 remote, 322 // 若仓库无 remote 或多 remote 则交回 git 自身的默认行为(保持向后兼容,让 git 报错)。 323 if !withCreds { 324 target := remote 325 if target == "" { 326 name, err := g.autoSelectRemote(ctx, path, opts) 327 if err != nil { 328 // `git remote` 自身失败(路径非仓库等)时直接返回原始错误, 329 // 避免被后续 buildArgs 转成误导性的 InvalidArgumentError。 330 return nil, err 331 } 332 target = name 333 } 334 args, err := buildArgs(target) 335 if err != nil { 336 return nil, err 337 } 338 result, err := g.runGit(ctx, sub, args, path, opts) 339 if err != nil { 340 return nil, mapPushPullError(err, sub, username, password) 341 } 342 return result, nil 343 } 344 345 // 带凭证时必须解析出确切的 remote 名(含 URL),用于注入/恢复凭证。 346 remoteName, originalURL, err := g.resolveRemoteName(ctx, path, remote, opts) 347 if err != nil { 348 return nil, err 349 } 350 args, err := buildArgs(remoteName) 351 if err != nil { 352 return nil, err 353 } 354 var result *CommandResult 355 err = g.withRemoteCredentials(ctx, path, remoteName, originalURL, username, password, opts, func() error { 356 r, runErr := g.runGit(ctx, sub, args, path, opts) 357 result = r 358 return runErr 359 }) 360 if err != nil { 361 return nil, mapPushPullError(err, sub, username, password) 362 } 363 return result, nil 364 } 365 366 // autoSelectRemote 在仓库恰好有一个 remote 时返回 (name, nil); 367 // 仓库为 0 / 多 remote 时返回 ("", nil),由调用方决定是否兜底到 git 默认行为; 368 // `git remote` 自身执行失败(路径不是仓库 / 仓库不可访问 / git 异常)时返回 ("", err), 369 // 避免把真实仓库错误掩盖成"需要显式传 Remote"。 370 func (g *Git) autoSelectRemote(ctx context.Context, path string, opts *GitOptions) (string, error) { 371 remotes, err := g.listRemotes(ctx, path, opts) 372 if err != nil { 373 return "", err 374 } 375 if len(remotes) == 1 { 376 return remotes[0], nil 377 } 378 return "", nil 379 } 380 381 // listRemotes 返回仓库当前配置的全部 remote 名(已去除空行与首尾空白)。 382 func (g *Git) listRemotes(ctx context.Context, path string, opts *GitOptions) ([]string, error) { 383 result, err := g.runGit(ctx, "remote", []string{"remote"}, path, opts) 384 if err != nil { 385 return nil, err 386 } 387 var remotes []string 388 for _, line := range strings.Split(result.Stdout, "\n") { 389 if s := strings.TrimSpace(line); s != "" { 390 remotes = append(remotes, s) 391 } 392 } 393 return remotes, nil 394 } 395 396 // RemoteAdd 为仓库添加(或在 opts.Overwrite=true 时覆盖)一个 remote。 397 func (g *Git) RemoteAdd(ctx context.Context, path, name, repoURL string, opts *RemoteAddOptions) (*CommandResult, error) { 398 if opts == nil { 399 opts = &RemoteAddOptions{} 400 } 401 addArgs, err := buildRemoteAddArgs(name, repoURL) 402 if err != nil { 403 return nil, err 404 } 405 406 // 构造 add-or-overwrite 阶段:不带 fetch,避免 add 失败后 set-url fallback 与 add -f 的语义混淆。 407 var addPhase string 408 if opts.Overwrite { 409 addCmd := buildGitCommand(addArgs, path) 410 setURLCmd := buildGitCommand(buildRemoteSetURLArgs(name, repoURL), path) 411 addPhase = addCmd + " || " + setURLCmd 412 } else { 413 addPhase = buildGitCommand(addArgs, path) 414 } 415 416 // 不需要 fetch 时直接执行 add 阶段。 417 if !opts.Fetch { 418 return g.runShell(ctx, "remote", addPhase, &opts.GitOptions) 419 } 420 421 // 需要 fetch 时统一拼一次:add 阶段成功后仅 fetch 一次,避免重复网络请求。 422 fetchCmd := buildGitCommand([]string{"fetch", name}, path) 423 return g.runShell(ctx, "remote", "("+addPhase+") && "+fetchCmd, &opts.GitOptions) 424 } 425 426 // RemoteGet 获取指定 remote 的 URL,未配置时返回空字符串。 427 func (g *Git) RemoteGet(ctx context.Context, path, name string, opts *GitOptions) (string, error) { 428 if name == "" { 429 return "", &InvalidArgumentError{Msg: "Remote name is required."} 430 } 431 result, err := g.runGit(ctx, "remote", buildRemoteGetURLArgs(name), path, opts) 432 if err != nil { 433 // `git remote get-url <name>` 在 remote 不存在时退出码为 2;其他退出码视为真正的错误。 434 var ce *gitCommandError 435 if errors.As(err, &ce) && ce.Result != nil && ce.Result.ExitCode == 2 { 436 return "", nil 437 } 438 return "", err 439 } 440 return strings.TrimSpace(result.Stdout), nil 441 } 442 443 // SetConfig 设置 git config 值。 444 // 使用 GitConfigScopeLocal 时必须同时提供 opts.Path。 445 func (g *Git) SetConfig(ctx context.Context, key, value string, opts *ConfigOptions) (*CommandResult, error) { 446 if key == "" { 447 return nil, &InvalidArgumentError{Msg: "Git config key is required."} 448 } 449 if opts == nil { 450 opts = &ConfigOptions{} 451 } 452 scope, repoPath, err := resolveConfigScope(opts.Scope, opts.Path) 453 if err != nil { 454 return nil, err 455 } 456 return g.runGit(ctx, "config", []string{"config", scope, key, value}, repoPath, &opts.GitOptions) 457 } 458 459 // GetConfig 获取 git config 值,未配置时返回空字符串。 460 func (g *Git) GetConfig(ctx context.Context, key string, opts *ConfigOptions) (string, error) { 461 if key == "" { 462 return "", &InvalidArgumentError{Msg: "Git config key is required."} 463 } 464 if opts == nil { 465 opts = &ConfigOptions{} 466 } 467 scope, repoPath, err := resolveConfigScope(opts.Scope, opts.Path) 468 if err != nil { 469 return "", err 470 } 471 result, err := g.runGit(ctx, "config", []string{"config", scope, "--get", key}, repoPath, &opts.GitOptions) 472 if err != nil { 473 // `git config --get` 未找到值时退出码为 1,stdout/stderr 为空;其他退出码视为真正的错误。 474 var ce *gitCommandError 475 if errors.As(err, &ce) && ce.Result != nil && ce.Result.ExitCode == 1 && 476 strings.TrimSpace(ce.Result.Stdout) == "" && strings.TrimSpace(ce.Result.Stderr) == "" { 477 return "", nil 478 } 479 return "", err 480 } 481 return strings.TrimSpace(result.Stdout), nil 482 } 483 484 // ConfigureUser 设置 git 提交用户名与邮箱(一次 RPC 内完成)。 485 func (g *Git) ConfigureUser(ctx context.Context, name, email string, opts *ConfigOptions) (*CommandResult, error) { 486 if name == "" || email == "" { 487 return nil, &InvalidArgumentError{Msg: "Both name and email are required."} 488 } 489 if opts == nil { 490 opts = &ConfigOptions{} 491 } 492 scope, repoPath, err := resolveConfigScope(opts.Scope, opts.Path) 493 if err != nil { 494 return nil, err 495 } 496 nameCmd := buildGitCommand([]string{"config", scope, "user.name", name}, repoPath) 497 emailCmd := buildGitCommand([]string{"config", scope, "user.email", email}, repoPath) 498 return g.runShell(ctx, "config", nameCmd+" && "+emailCmd, &opts.GitOptions) 499 } 500 501 // DangerouslyAuthenticate 通过 git credential helper 将凭证持久化到磁盘。 502 // 503 // 该方法会为后续所有 git 操作生效;建议优先使用短生命周期 token,并谨慎使用此方法。 504 func (g *Git) DangerouslyAuthenticate(ctx context.Context, opts *AuthenticateOptions) (*CommandResult, error) { 505 if opts == nil || opts.Username == "" || opts.Password == "" { 506 return nil, &InvalidArgumentError{Msg: "Both username and password are required to authenticate git."} 507 } 508 509 host := strings.TrimSpace(opts.Host) 510 if host == "" { 511 host = "github.com" 512 } 513 protocol := strings.TrimSpace(opts.Protocol) 514 if protocol == "" { 515 protocol = "https" 516 } 517 if protocol != "https" { 518 return nil, &InvalidArgumentError{Msg: "Only https protocol is supported for git authentication."} 519 } 520 521 if _, err := g.runGit(ctx, "config", []string{"config", "--global", "credential.helper", "store"}, "", &opts.GitOptions); err != nil { 522 return nil, err 523 } 524 525 credentialInput := strings.Join([]string{ 526 "protocol=" + protocol, 527 "host=" + host, 528 "username=" + opts.Username, 529 "password=" + opts.Password, 530 "", 531 "", 532 }, "\n") 533 cmd := fmt.Sprintf("printf '%%s' %s | %s", shellEscape(credentialInput), buildGitCommand([]string{"credential", "approve"}, "")) 534 return g.runShell(ctx, "credential", cmd, &opts.GitOptions) 535 } 536 537 // resolveConfigScope 校验并返回 git config 的 scope 标志与仓库路径。 538 func resolveConfigScope(scope GitConfigScope, repoPath string) (string, string, error) { 539 if scope == "" { 540 scope = GitConfigScopeGlobal 541 } 542 switch scope { 543 case GitConfigScopeGlobal: 544 return "--global", "", nil 545 case GitConfigScopeSystem: 546 return "--system", "", nil 547 case GitConfigScopeLocal: 548 if repoPath == "" { 549 return "", "", &InvalidArgumentError{Msg: "Repository path is required for local git config scope."} 550 } 551 return "--local", repoPath, nil 552 } 553 return "", "", &InvalidArgumentError{Msg: "Unsupported git config scope: " + string(scope)} 554 } 555 556 // hasUpstream 检查当前分支是否配置了 upstream。 557 func (g *Git) hasUpstream(ctx context.Context, path string, opts *GitOptions) (bool, error) { 558 _, err := g.runGit(ctx, "rev-parse", buildHasUpstreamArgs(), path, opts) 559 if err == nil { 560 return true, nil 561 } 562 // 未配置 upstream 时 rev-parse 退出码为 128,stderr 含 "no upstream" 等说明; 563 // 其他失败(仓库路径错误、git 自身问题)应原样向上抛出。 564 var ce *gitCommandError 565 if errors.As(err, &ce) && ce.Result != nil && isMissingUpstream(err) { 566 return false, nil 567 } 568 return false, err 569 } 570 571 // resolveRemoteName 在凭证注入流程中确定要使用的 remote 名,并一并返回其 URL。 572 // 573 // 解析规则与 E2B 对齐: 574 // - 显式指定 remote 时直接使用。 575 // - 未指定时通过 `git remote` 列出全部 remote:恰好一个时自动使用,多个时报错(要求显式指定)。 576 // 577 // 一并返回 URL 是为了让 withRemoteCredentials 复用,避免再发一次 RPC。 578 func (g *Git) resolveRemoteName(ctx context.Context, path, remote string, opts *GitOptions) (string, string, error) { 579 name := remote 580 if name == "" { 581 remotes, err := g.listRemotes(ctx, path, opts) 582 if err != nil { 583 return "", "", err 584 } 585 switch len(remotes) { 586 case 0: 587 return "", "", &InvalidArgumentError{Msg: "Repository has no remote configured."} 588 case 1: 589 name = remotes[0] 590 default: 591 return "", "", &InvalidArgumentError{Msg: "Remote is required when using username/password and the repository has multiple remotes."} 592 } 593 } 594 595 url, err := g.RemoteGet(ctx, path, name, opts) 596 if err != nil { 597 return "", "", err 598 } 599 if url == "" { 600 return "", "", &InvalidArgumentError{Msg: fmt.Sprintf("Remote %q is not configured.", name)} 601 } 602 return name, url, nil 603 } 604 605 // withRemoteCredentials 临时把 username/password 注入指定 remote 的 URL, 606 // 在 fn 执行完成后无论成功或失败都恢复原 URL。originalURL 由调用方传入以避免重复 RPC。 607 func (g *Git) withRemoteCredentials(ctx context.Context, path, remote, originalURL, username, password string, opts *GitOptions, fn func() error) (err error) { 608 authedURL, err := withCredentials(originalURL, username, password) 609 if err != nil { 610 return err 611 } 612 if authedURL == originalURL { 613 return fn() 614 } 615 616 if _, err = g.runGit(ctx, "remote", buildRemoteSetURLArgs(remote, authedURL), path, opts); err != nil { 617 return err 618 } 619 defer func() { 620 // 即使 ctx 已取消也要恢复原 URL,避免凭证遗留在 .git/config。 621 // 用独立的带超时 ctx,防止沙箱异常时 defer 永久阻塞。 622 // 恢复 URL 是安全相关步骤,主操作错误不应吞掉它;用 errors.Join 同时保留两者。 623 cleanupCtx, cancel := context.WithTimeout(context.Background(), credentialCleanupTimeout) 624 defer cancel() 625 _, restoreErr := g.runGit(cleanupCtx, "remote", buildRemoteSetURLArgs(remote, originalURL), path, opts) 626 err = errors.Join(err, restoreErr) 627 }() 628 return fn() 629 } 630 631 // runGit 拼装并执行一条 git 命令。 632 func (g *Git) runGit(ctx context.Context, sub string, args []string, repoPath string, opts *GitOptions) (*CommandResult, error) { 633 cmd := buildGitCommand(args, repoPath) 634 return g.runShell(ctx, sub, cmd, opts) 635 } 636 637 // runShell 以 shell 形式执行命令,统一注入默认 git 环境变量。 638 func (g *Git) runShell(ctx context.Context, sub, cmd string, opts *GitOptions) (*CommandResult, error) { 639 cmdOpts := buildCommandOptions(opts) 640 result, err := g.commands.Run(ctx, cmd, cmdOpts...) 641 if err != nil { 642 return nil, fmt.Errorf("git %s: %w", sub, err) 643 } 644 if result.ExitCode != 0 { 645 return result, &gitCommandError{Cmd: sub, Result: result} 646 } 647 return result, nil 648 } 649 650 // buildCommandOptions 将 GitOptions 转换为 CommandOption 列表。 651 func buildCommandOptions(opts *GitOptions) []CommandOption { 652 capacity := len(defaultGitEnv) 653 if opts != nil { 654 capacity += len(opts.Envs) 655 } 656 mergedEnvs := make(map[string]string, capacity) 657 cmdOpts := []CommandOption{} 658 if opts != nil { 659 maps.Copy(mergedEnvs, opts.Envs) 660 if opts.User != "" { 661 cmdOpts = append(cmdOpts, WithCommandUser(opts.User)) 662 } 663 if opts.Cwd != "" { 664 cmdOpts = append(cmdOpts, WithCwd(opts.Cwd)) 665 } 666 if opts.Timeout > 0 { 667 cmdOpts = append(cmdOpts, WithTimeout(opts.Timeout)) 668 } 669 } 670 // 默认环境必须最后写入,避免被调用方覆盖(例如禁用 GIT_TERMINAL_PROMPT)。 671 maps.Copy(mergedEnvs, defaultGitEnv) 672 cmdOpts = append(cmdOpts, WithEnvs(mergedEnvs)) 673 return cmdOpts 674 } 675 676 // mapPushPullError 将 push/pull 的命令错误映射为类型化 git 错误。 677 func mapPushPullError(err error, action, username, password string) error { 678 if err == nil { 679 return nil 680 } 681 if isAuthFailure(err) { 682 return &GitAuthError{Msg: buildAuthErrorMessage(action, username != "" && password == ""), Err: err} 683 } 684 if isMissingUpstream(err) { 685 return &GitUpstreamError{Msg: buildUpstreamErrorMessage(action), Err: err} 686 } 687 return err 688 }