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  }