Extract workspace subpackage with pure repo helpers
Moves the stateless parts of the workspace subsystem into internal/daemon/workspace: - RepoSpec struct + InspectRepo for host-side git inspection - ImportRepoToGuest (taking a minimal GuestClient interface) with the full-copy and metadata-only / shallow-overlay paths - FinalizeScript, PrepareRepoCopy, ResolveSourcePath - ListSubmodules, ListOverlayPaths, ParsePrepareMode - Git helpers (GitOutput, GitTrimmedOutput, GitResolvedConfigValue, ParseNullSeparatedOutput, RunHostCommand, GitFileURL) and the HostCommandOutputFunc test seam - ShallowFetchDepth const The subpackage imports internal/daemon/session for ShellQuote and FormatStepError so both workspace and session pure helpers live in their own subpackages with a clean session→workspace direction of use. daemon/workspace.go shrinks from 481 → 156 LOC, keeping just the three orchestrator methods (Export, Prepare, prepareLocked) that still touch d.store, d.FindVM, d.dialGuest, d.waitForGuestSSH, and the VM lock set. guestSessionHostCommandOutputFunc is removed from guest_sessions.go (its only caller was workspace.go; the new package has its own copy). All tests green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
37e02b1576
commit
1d51370d26
3 changed files with 404 additions and 343 deletions
|
|
@ -18,20 +18,6 @@ import (
|
||||||
"banger/internal/system"
|
"banger/internal/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
var guestSessionHostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
||||||
runner := system.NewRunner()
|
|
||||||
output, err := runner.Run(ctx, name, args...)
|
|
||||||
if err == nil {
|
|
||||||
return output, nil
|
|
||||||
}
|
|
||||||
command := strings.TrimSpace(strings.Join(append([]string{name}, args...), " "))
|
|
||||||
detail := strings.TrimSpace(string(output))
|
|
||||||
if detail == "" {
|
|
||||||
return output, fmt.Errorf("%s: %w", command, err)
|
|
||||||
}
|
|
||||||
return output, fmt.Errorf("%s: %w: %s", command, err, detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
type guestSSHClient interface {
|
type guestSSHClient interface {
|
||||||
Close() error
|
Close() error
|
||||||
RunScript(context.Context, string, io.Writer) error
|
RunScript(context.Context, string, io.Writer) error
|
||||||
|
|
|
||||||
|
|
@ -6,36 +6,16 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"banger/internal/api"
|
"banger/internal/api"
|
||||||
sess "banger/internal/daemon/session"
|
sess "banger/internal/daemon/session"
|
||||||
|
ws "banger/internal/daemon/workspace"
|
||||||
"banger/internal/model"
|
"banger/internal/model"
|
||||||
"banger/internal/system"
|
"banger/internal/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
const workspaceShallowFetchDepth = 10
|
|
||||||
|
|
||||||
type workspaceRepoSpec struct {
|
|
||||||
SourcePath string
|
|
||||||
RepoRoot string
|
|
||||||
RepoName string
|
|
||||||
HeadCommit string
|
|
||||||
CurrentBranch string
|
|
||||||
BranchName string
|
|
||||||
BaseCommit string
|
|
||||||
OriginURL string
|
|
||||||
GitUserName string
|
|
||||||
GitUserEmail string
|
|
||||||
OverlayPaths []string
|
|
||||||
Submodules []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Daemon) ExportVMWorkspace(ctx context.Context, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error) {
|
func (d *Daemon) ExportVMWorkspace(ctx context.Context, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error) {
|
||||||
guestPath := strings.TrimSpace(params.GuestPath)
|
guestPath := strings.TrimSpace(params.GuestPath)
|
||||||
if guestPath == "" {
|
if guestPath == "" {
|
||||||
|
|
@ -101,7 +81,7 @@ func (d *Daemon) ExportVMWorkspace(ctx context.Context, params api.WorkspaceExpo
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Daemon) PrepareVMWorkspace(ctx context.Context, params api.VMWorkspacePrepareParams) (model.WorkspacePrepareResult, error) {
|
func (d *Daemon) PrepareVMWorkspace(ctx context.Context, params api.VMWorkspacePrepareParams) (model.WorkspacePrepareResult, error) {
|
||||||
mode, err := parseWorkspacePrepareMode(params.Mode)
|
mode, err := ws.ParsePrepareMode(params.Mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.WorkspacePrepareResult{}, err
|
return model.WorkspacePrepareResult{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -133,7 +113,7 @@ func (d *Daemon) PrepareVMWorkspace(ctx context.Context, params api.VMWorkspaceP
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Daemon) prepareVMWorkspaceLocked(ctx context.Context, vm model.VMRecord, sourcePath, guestPath, branchName, fromRef string, mode model.WorkspacePrepareMode, readOnly bool) (model.WorkspacePrepareResult, error) {
|
func (d *Daemon) prepareVMWorkspaceLocked(ctx context.Context, vm model.VMRecord, sourcePath, guestPath, branchName, fromRef string, mode model.WorkspacePrepareMode, readOnly bool) (model.WorkspacePrepareResult, error) {
|
||||||
spec, err := inspectWorkspaceRepo(ctx, sourcePath, branchName, fromRef)
|
spec, err := ws.InspectRepo(ctx, sourcePath, branchName, fromRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.WorkspacePrepareResult{}, err
|
return model.WorkspacePrepareResult{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -149,7 +129,7 @@ func (d *Daemon) prepareVMWorkspaceLocked(ctx context.Context, vm model.VMRecord
|
||||||
return model.WorkspacePrepareResult{}, fmt.Errorf("dial guest ssh: %w", err)
|
return model.WorkspacePrepareResult{}, fmt.Errorf("dial guest ssh: %w", err)
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
if err := importWorkspaceRepoToGuest(ctx, client, spec, guestPath, mode); err != nil {
|
if err := ws.ImportRepoToGuest(ctx, client, spec, guestPath, mode); err != nil {
|
||||||
return model.WorkspacePrepareResult{}, err
|
return model.WorkspacePrepareResult{}, err
|
||||||
}
|
}
|
||||||
if readOnly {
|
if readOnly {
|
||||||
|
|
@ -174,308 +154,3 @@ func (d *Daemon) prepareVMWorkspaceLocked(ctx context.Context, vm model.VMRecord
|
||||||
PreparedAt: model.Now(),
|
PreparedAt: model.Now(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func inspectWorkspaceRepo(ctx context.Context, rawPath, branchName, fromRef string) (workspaceRepoSpec, error) {
|
|
||||||
sourcePath, err := resolveWorkspaceSourcePath(rawPath)
|
|
||||||
if err != nil {
|
|
||||||
return workspaceRepoSpec{}, err
|
|
||||||
}
|
|
||||||
repoRoot, err := workspaceGitTrimmedOutput(ctx, sourcePath, "rev-parse", "--show-toplevel")
|
|
||||||
if err != nil {
|
|
||||||
return workspaceRepoSpec{}, fmt.Errorf("%s is not inside a git repository", sourcePath)
|
|
||||||
}
|
|
||||||
isBare, err := workspaceGitTrimmedOutput(ctx, repoRoot, "rev-parse", "--is-bare-repository")
|
|
||||||
if err != nil {
|
|
||||||
return workspaceRepoSpec{}, fmt.Errorf("inspect git repository %s: %w", repoRoot, err)
|
|
||||||
}
|
|
||||||
if isBare == "true" {
|
|
||||||
return workspaceRepoSpec{}, fmt.Errorf("workspace prepare requires a non-bare git repository: %s", repoRoot)
|
|
||||||
}
|
|
||||||
submodules, err := listWorkspaceSubmodules(ctx, repoRoot)
|
|
||||||
if err != nil {
|
|
||||||
return workspaceRepoSpec{}, err
|
|
||||||
}
|
|
||||||
headCommit, err := workspaceGitTrimmedOutput(ctx, repoRoot, "rev-parse", "HEAD^{commit}")
|
|
||||||
if err != nil {
|
|
||||||
return workspaceRepoSpec{}, fmt.Errorf("git repository %s must have at least one commit", repoRoot)
|
|
||||||
}
|
|
||||||
currentBranch, err := workspaceGitTrimmedOutput(ctx, repoRoot, "branch", "--show-current")
|
|
||||||
if err != nil {
|
|
||||||
return workspaceRepoSpec{}, fmt.Errorf("resolve current branch for %s: %w", repoRoot, err)
|
|
||||||
}
|
|
||||||
baseCommit := headCommit
|
|
||||||
branchName = strings.TrimSpace(branchName)
|
|
||||||
if branchName != "" {
|
|
||||||
baseCommit, err = workspaceGitTrimmedOutput(ctx, repoRoot, "rev-parse", fromRef+"^{commit}")
|
|
||||||
if err != nil {
|
|
||||||
return workspaceRepoSpec{}, fmt.Errorf("resolve workspace from %q: %w", fromRef, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
gitUserName, err := workspaceGitResolvedConfigValue(ctx, repoRoot, "user.name")
|
|
||||||
if err != nil {
|
|
||||||
return workspaceRepoSpec{}, fmt.Errorf("resolve git user.name for %s: %w", repoRoot, err)
|
|
||||||
}
|
|
||||||
gitUserEmail, err := workspaceGitResolvedConfigValue(ctx, repoRoot, "user.email")
|
|
||||||
if err != nil {
|
|
||||||
return workspaceRepoSpec{}, fmt.Errorf("resolve git user.email for %s: %w", repoRoot, err)
|
|
||||||
}
|
|
||||||
originURL, err := workspaceGitResolvedConfigValue(ctx, repoRoot, "remote.origin.url")
|
|
||||||
if err != nil {
|
|
||||||
return workspaceRepoSpec{}, fmt.Errorf("resolve origin url for %s: %w", repoRoot, err)
|
|
||||||
}
|
|
||||||
overlayPaths, err := listWorkspaceOverlayPaths(ctx, repoRoot)
|
|
||||||
if err != nil {
|
|
||||||
return workspaceRepoSpec{}, err
|
|
||||||
}
|
|
||||||
return workspaceRepoSpec{
|
|
||||||
SourcePath: sourcePath,
|
|
||||||
RepoRoot: repoRoot,
|
|
||||||
RepoName: filepath.Base(repoRoot),
|
|
||||||
HeadCommit: headCommit,
|
|
||||||
CurrentBranch: currentBranch,
|
|
||||||
BranchName: branchName,
|
|
||||||
BaseCommit: baseCommit,
|
|
||||||
OriginURL: originURL,
|
|
||||||
GitUserName: gitUserName,
|
|
||||||
GitUserEmail: gitUserEmail,
|
|
||||||
OverlayPaths: overlayPaths,
|
|
||||||
Submodules: submodules,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func importWorkspaceRepoToGuest(ctx context.Context, client guestSSHClient, spec workspaceRepoSpec, guestPath string, mode model.WorkspacePrepareMode) error {
|
|
||||||
switch mode {
|
|
||||||
case model.WorkspacePrepareModeFullCopy:
|
|
||||||
var copyLog bytes.Buffer
|
|
||||||
command := fmt.Sprintf("rm -rf %s && mkdir -p %s && tar -o -C %s --strip-components=1 -xf -", sess.ShellQuote(guestPath), sess.ShellQuote(guestPath), sess.ShellQuote(guestPath))
|
|
||||||
if err := client.StreamTar(ctx, spec.RepoRoot, command, ©Log); err != nil {
|
|
||||||
return sess.FormatStepError("copy full workspace", err, copyLog.String())
|
|
||||||
}
|
|
||||||
var finalizeLog bytes.Buffer
|
|
||||||
if err := client.RunScript(ctx, workspaceFinalizeScript(spec, guestPath, mode), &finalizeLog); err != nil {
|
|
||||||
return sess.FormatStepError("finalize full workspace", err, finalizeLog.String())
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
case model.WorkspacePrepareModeMetadataOnly, model.WorkspacePrepareModeShallowOverlay:
|
|
||||||
repoCopyDir, cleanup, err := prepareWorkspaceRepoCopy(ctx, spec)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer cleanup()
|
|
||||||
var copyLog bytes.Buffer
|
|
||||||
command := fmt.Sprintf("rm -rf %s && mkdir -p %s && tar -o -C %s --strip-components=1 -xf -", sess.ShellQuote(guestPath), sess.ShellQuote(guestPath), sess.ShellQuote(guestPath))
|
|
||||||
if err := client.StreamTar(ctx, repoCopyDir, command, ©Log); err != nil {
|
|
||||||
return sess.FormatStepError("copy guest git metadata", err, copyLog.String())
|
|
||||||
}
|
|
||||||
var scriptLog bytes.Buffer
|
|
||||||
if err := client.RunScript(ctx, workspaceFinalizeScript(spec, guestPath, mode), &scriptLog); err != nil {
|
|
||||||
return sess.FormatStepError("prepare guest checkout", err, scriptLog.String())
|
|
||||||
}
|
|
||||||
if mode == model.WorkspacePrepareModeMetadataOnly {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var overlayLog bytes.Buffer
|
|
||||||
command = fmt.Sprintf("tar -o -C %s --strip-components=1 -xf -", sess.ShellQuote(guestPath))
|
|
||||||
if err := client.StreamTarEntries(ctx, spec.RepoRoot, spec.OverlayPaths, command, &overlayLog); err != nil {
|
|
||||||
return sess.FormatStepError("overlay workspace working tree", err, overlayLog.String())
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported workspace mode %q", mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func workspaceFinalizeScript(spec workspaceRepoSpec, guestPath string, mode model.WorkspacePrepareMode) string {
|
|
||||||
var script strings.Builder
|
|
||||||
script.WriteString("set -euo pipefail\n")
|
|
||||||
fmt.Fprintf(&script, "DIR=%s\n", sess.ShellQuote(guestPath))
|
|
||||||
script.WriteString("git config --global --add safe.directory \"$DIR\"\n")
|
|
||||||
if mode != model.WorkspacePrepareModeFullCopy {
|
|
||||||
script.WriteString("find \"$DIR\" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +\n")
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case strings.TrimSpace(spec.BranchName) != "":
|
|
||||||
fmt.Fprintf(&script, "git -C \"$DIR\" checkout -B %s %s\n", sess.ShellQuote(spec.BranchName), sess.ShellQuote(spec.BaseCommit))
|
|
||||||
case strings.TrimSpace(spec.CurrentBranch) != "":
|
|
||||||
fmt.Fprintf(&script, "git -C \"$DIR\" checkout -B %s %s\n", sess.ShellQuote(spec.CurrentBranch), sess.ShellQuote(spec.HeadCommit))
|
|
||||||
default:
|
|
||||||
fmt.Fprintf(&script, "git -C \"$DIR\" checkout --detach %s\n", sess.ShellQuote(spec.HeadCommit))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(spec.GitUserName) != "" && strings.TrimSpace(spec.GitUserEmail) != "" {
|
|
||||||
fmt.Fprintf(&script, "git -C \"$DIR\" config user.name %s\n", sess.ShellQuote(spec.GitUserName))
|
|
||||||
fmt.Fprintf(&script, "git -C \"$DIR\" config user.email %s\n", sess.ShellQuote(spec.GitUserEmail))
|
|
||||||
}
|
|
||||||
return script.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareWorkspaceRepoCopy(ctx context.Context, spec workspaceRepoSpec) (string, func(), error) {
|
|
||||||
tempRoot, err := os.MkdirTemp("", "banger-workspace-*")
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
cleanup := func() { _ = os.RemoveAll(tempRoot) }
|
|
||||||
repoCopyDir := filepath.Join(tempRoot, spec.RepoName)
|
|
||||||
cloneArgs := []string{"clone", "--no-checkout", "--depth", fmt.Sprintf("%d", workspaceShallowFetchDepth)}
|
|
||||||
if strings.TrimSpace(spec.CurrentBranch) != "" {
|
|
||||||
cloneArgs = append(cloneArgs, "--single-branch", "--branch", spec.CurrentBranch)
|
|
||||||
}
|
|
||||||
cloneArgs = append(cloneArgs, workspaceGitFileURL(spec.RepoRoot), repoCopyDir)
|
|
||||||
if err := workspaceRunHostCommand(ctx, "git", cloneArgs...); err != nil {
|
|
||||||
cleanup()
|
|
||||||
return "", nil, fmt.Errorf("clone shallow workspace repo copy: %w", err)
|
|
||||||
}
|
|
||||||
checkoutCommit := spec.HeadCommit
|
|
||||||
if strings.TrimSpace(spec.BranchName) != "" {
|
|
||||||
checkoutCommit = spec.BaseCommit
|
|
||||||
}
|
|
||||||
if err := workspaceRunHostCommand(ctx, "git", "-C", repoCopyDir, "cat-file", "-e", checkoutCommit+"^{commit}"); err != nil {
|
|
||||||
if err := workspaceRunHostCommand(ctx, "git", "-C", repoCopyDir, "fetch", "--depth", fmt.Sprintf("%d", workspaceShallowFetchDepth), workspaceGitFileURL(spec.RepoRoot), checkoutCommit); err != nil {
|
|
||||||
cleanup()
|
|
||||||
return "", nil, fmt.Errorf("fetch shallow workspace repo commit %s: %w", checkoutCommit, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(spec.OriginURL) != "" {
|
|
||||||
if err := workspaceRunHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "set-url", "origin", spec.OriginURL); err != nil {
|
|
||||||
cleanup()
|
|
||||||
return "", nil, fmt.Errorf("set workspace origin remote: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := workspaceRunHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "remove", "origin"); err != nil {
|
|
||||||
cleanup()
|
|
||||||
return "", nil, fmt.Errorf("remove workspace placeholder origin remote: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return repoCopyDir, cleanup, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveWorkspaceSourcePath(rawPath string) (string, error) {
|
|
||||||
if strings.TrimSpace(rawPath) == "" {
|
|
||||||
return "", errors.New("workspace source path is required")
|
|
||||||
}
|
|
||||||
absPath, err := filepath.Abs(rawPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
info, err := os.Stat(absPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
return "", fmt.Errorf("%s is not a directory", absPath)
|
|
||||||
}
|
|
||||||
return absPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func listWorkspaceSubmodules(ctx context.Context, repoRoot string) ([]string, error) {
|
|
||||||
output, err := workspaceGitOutput(ctx, repoRoot, "ls-files", "--stage", "-z")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("inspect workspace git index for %s: %w", repoRoot, err)
|
|
||||||
}
|
|
||||||
var submodules []string
|
|
||||||
for _, record := range workspaceParseNullSeparatedOutput(output) {
|
|
||||||
if !strings.HasPrefix(record, "160000 ") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, path, ok := strings.Cut(record, " ")
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
submodules = append(submodules, strings.TrimSpace(path))
|
|
||||||
}
|
|
||||||
sort.Strings(submodules)
|
|
||||||
return submodules, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func listWorkspaceOverlayPaths(ctx context.Context, repoRoot string) ([]string, error) {
|
|
||||||
trackedOutput, err := workspaceGitOutput(ctx, repoRoot, "ls-files", "-z")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list tracked files for %s: %w", repoRoot, err)
|
|
||||||
}
|
|
||||||
untrackedOutput, err := workspaceGitOutput(ctx, repoRoot, "ls-files", "--others", "--exclude-standard", "-z")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list untracked files for %s: %w", repoRoot, err)
|
|
||||||
}
|
|
||||||
paths := make([]string, 0)
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
for _, relPath := range workspaceParseNullSeparatedOutput(trackedOutput) {
|
|
||||||
if relPath == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, err := os.Lstat(filepath.Join(repoRoot, relPath)); err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
seen[relPath] = struct{}{}
|
|
||||||
paths = append(paths, relPath)
|
|
||||||
}
|
|
||||||
for _, relPath := range workspaceParseNullSeparatedOutput(untrackedOutput) {
|
|
||||||
if relPath == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[relPath]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[relPath] = struct{}{}
|
|
||||||
paths = append(paths, relPath)
|
|
||||||
}
|
|
||||||
sort.Strings(paths)
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseWorkspacePrepareMode(raw string) (model.WorkspacePrepareMode, error) {
|
|
||||||
switch strings.TrimSpace(raw) {
|
|
||||||
case "", string(model.WorkspacePrepareModeShallowOverlay):
|
|
||||||
return model.WorkspacePrepareModeShallowOverlay, nil
|
|
||||||
case string(model.WorkspacePrepareModeFullCopy):
|
|
||||||
return model.WorkspacePrepareModeFullCopy, nil
|
|
||||||
case string(model.WorkspacePrepareModeMetadataOnly):
|
|
||||||
return model.WorkspacePrepareModeMetadataOnly, nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("unsupported workspace mode %q", raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func workspaceGitOutput(ctx context.Context, dir string, args ...string) ([]byte, error) {
|
|
||||||
fullArgs := make([]string, 0, len(args)+2)
|
|
||||||
if strings.TrimSpace(dir) != "" {
|
|
||||||
fullArgs = append(fullArgs, "-C", dir)
|
|
||||||
}
|
|
||||||
fullArgs = append(fullArgs, args...)
|
|
||||||
return guestSessionHostCommandOutputFunc(ctx, "git", fullArgs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func workspaceGitTrimmedOutput(ctx context.Context, dir string, args ...string) (string, error) {
|
|
||||||
output, err := workspaceGitOutput(ctx, dir, args...)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(output)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func workspaceGitResolvedConfigValue(ctx context.Context, dir, key string) (string, error) {
|
|
||||||
return workspaceGitTrimmedOutput(ctx, dir, "config", "--default", "", "--get", key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func workspaceParseNullSeparatedOutput(output []byte) []string {
|
|
||||||
chunks := bytes.Split(output, []byte{0})
|
|
||||||
values := make([]string, 0, len(chunks))
|
|
||||||
for _, chunk := range chunks {
|
|
||||||
value := strings.TrimSpace(string(chunk))
|
|
||||||
if value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
values = append(values, value)
|
|
||||||
}
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
func workspaceRunHostCommand(ctx context.Context, name string, args ...string) error {
|
|
||||||
_, err := guestSessionHostCommandOutputFunc(ctx, name, args...)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func workspaceGitFileURL(path string) string {
|
|
||||||
return (&url.URL{Scheme: "file", Path: filepath.ToSlash(path)}).String()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
400
internal/daemon/workspace/workspace.go
Normal file
400
internal/daemon/workspace/workspace.go
Normal file
|
|
@ -0,0 +1,400 @@
|
||||||
|
// Package workspace contains the pure helpers of the workspace subsystem:
|
||||||
|
// git repo inspection, shallow copy preparation, guest-side tar import,
|
||||||
|
// finalization script generation, and small utilities.
|
||||||
|
//
|
||||||
|
// The orchestrator methods (ExportVMWorkspace, PrepareVMWorkspace) stay on
|
||||||
|
// *daemon.Daemon.
|
||||||
|
package workspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
sess "banger/internal/daemon/session"
|
||||||
|
"banger/internal/model"
|
||||||
|
"banger/internal/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShallowFetchDepth is the default --depth for the transient shallow clone
|
||||||
|
// used by metadata / overlay prepare modes.
|
||||||
|
const ShallowFetchDepth = 10
|
||||||
|
|
||||||
|
// RepoSpec describes the host-side git repository we're about to import into
|
||||||
|
// a guest. It captures the pieces both InspectRepo and the prepare flow need.
|
||||||
|
type RepoSpec struct {
|
||||||
|
SourcePath string
|
||||||
|
RepoRoot string
|
||||||
|
RepoName string
|
||||||
|
HeadCommit string
|
||||||
|
CurrentBranch string
|
||||||
|
BranchName string
|
||||||
|
BaseCommit string
|
||||||
|
OriginURL string
|
||||||
|
GitUserName string
|
||||||
|
GitUserEmail string
|
||||||
|
OverlayPaths []string
|
||||||
|
Submodules []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuestClient is the narrow subset of guest SSH operations needed by
|
||||||
|
// ImportRepoToGuest. Satisfied by the daemon-package guestSSHClient.
|
||||||
|
type GuestClient interface {
|
||||||
|
RunScript(ctx context.Context, script string, log io.Writer) error
|
||||||
|
StreamTar(ctx context.Context, dir, command string, log io.Writer) error
|
||||||
|
StreamTarEntries(ctx context.Context, dir string, entries []string, command string, log io.Writer) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostCommandOutputFunc runs a host command and returns its combined output.
|
||||||
|
// Declared as a package var so tests can substitute a stub runner.
|
||||||
|
var HostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
runner := system.NewRunner()
|
||||||
|
output, err := runner.Run(ctx, name, args...)
|
||||||
|
if err == nil {
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
command := strings.TrimSpace(strings.Join(append([]string{name}, args...), " "))
|
||||||
|
detail := strings.TrimSpace(string(output))
|
||||||
|
if detail == "" {
|
||||||
|
return output, fmt.Errorf("%s: %w", command, err)
|
||||||
|
}
|
||||||
|
return output, fmt.Errorf("%s: %w: %s", command, err, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectRepo resolves rawPath into an absolute repo root and captures the
|
||||||
|
// HEAD, branch, optional base-from ref, git identity, origin URL, submodules,
|
||||||
|
// and overlay paths (tracked + untracked non-ignored files) needed for a
|
||||||
|
// prepare.
|
||||||
|
func InspectRepo(ctx context.Context, rawPath, branchName, fromRef string) (RepoSpec, error) {
|
||||||
|
sourcePath, err := ResolveSourcePath(rawPath)
|
||||||
|
if err != nil {
|
||||||
|
return RepoSpec{}, err
|
||||||
|
}
|
||||||
|
repoRoot, err := GitTrimmedOutput(ctx, sourcePath, "rev-parse", "--show-toplevel")
|
||||||
|
if err != nil {
|
||||||
|
return RepoSpec{}, fmt.Errorf("%s is not inside a git repository", sourcePath)
|
||||||
|
}
|
||||||
|
isBare, err := GitTrimmedOutput(ctx, repoRoot, "rev-parse", "--is-bare-repository")
|
||||||
|
if err != nil {
|
||||||
|
return RepoSpec{}, fmt.Errorf("inspect git repository %s: %w", repoRoot, err)
|
||||||
|
}
|
||||||
|
if isBare == "true" {
|
||||||
|
return RepoSpec{}, fmt.Errorf("workspace prepare requires a non-bare git repository: %s", repoRoot)
|
||||||
|
}
|
||||||
|
submodules, err := ListSubmodules(ctx, repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
return RepoSpec{}, err
|
||||||
|
}
|
||||||
|
headCommit, err := GitTrimmedOutput(ctx, repoRoot, "rev-parse", "HEAD^{commit}")
|
||||||
|
if err != nil {
|
||||||
|
return RepoSpec{}, fmt.Errorf("git repository %s must have at least one commit", repoRoot)
|
||||||
|
}
|
||||||
|
currentBranch, err := GitTrimmedOutput(ctx, repoRoot, "branch", "--show-current")
|
||||||
|
if err != nil {
|
||||||
|
return RepoSpec{}, fmt.Errorf("resolve current branch for %s: %w", repoRoot, err)
|
||||||
|
}
|
||||||
|
baseCommit := headCommit
|
||||||
|
branchName = strings.TrimSpace(branchName)
|
||||||
|
if branchName != "" {
|
||||||
|
baseCommit, err = GitTrimmedOutput(ctx, repoRoot, "rev-parse", fromRef+"^{commit}")
|
||||||
|
if err != nil {
|
||||||
|
return RepoSpec{}, fmt.Errorf("resolve workspace from %q: %w", fromRef, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gitUserName, err := GitResolvedConfigValue(ctx, repoRoot, "user.name")
|
||||||
|
if err != nil {
|
||||||
|
return RepoSpec{}, fmt.Errorf("resolve git user.name for %s: %w", repoRoot, err)
|
||||||
|
}
|
||||||
|
gitUserEmail, err := GitResolvedConfigValue(ctx, repoRoot, "user.email")
|
||||||
|
if err != nil {
|
||||||
|
return RepoSpec{}, fmt.Errorf("resolve git user.email for %s: %w", repoRoot, err)
|
||||||
|
}
|
||||||
|
originURL, err := GitResolvedConfigValue(ctx, repoRoot, "remote.origin.url")
|
||||||
|
if err != nil {
|
||||||
|
return RepoSpec{}, fmt.Errorf("resolve origin url for %s: %w", repoRoot, err)
|
||||||
|
}
|
||||||
|
overlayPaths, err := ListOverlayPaths(ctx, repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
return RepoSpec{}, err
|
||||||
|
}
|
||||||
|
return RepoSpec{
|
||||||
|
SourcePath: sourcePath,
|
||||||
|
RepoRoot: repoRoot,
|
||||||
|
RepoName: filepath.Base(repoRoot),
|
||||||
|
HeadCommit: headCommit,
|
||||||
|
CurrentBranch: currentBranch,
|
||||||
|
BranchName: branchName,
|
||||||
|
BaseCommit: baseCommit,
|
||||||
|
OriginURL: originURL,
|
||||||
|
GitUserName: gitUserName,
|
||||||
|
GitUserEmail: gitUserEmail,
|
||||||
|
OverlayPaths: overlayPaths,
|
||||||
|
Submodules: submodules,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportRepoToGuest materialises spec inside the guest at guestPath. Mode
|
||||||
|
// selects between full copy, metadata-only, or shallow metadata + overlay.
|
||||||
|
func ImportRepoToGuest(ctx context.Context, client GuestClient, spec RepoSpec, guestPath string, mode model.WorkspacePrepareMode) error {
|
||||||
|
switch mode {
|
||||||
|
case model.WorkspacePrepareModeFullCopy:
|
||||||
|
var copyLog bytes.Buffer
|
||||||
|
command := fmt.Sprintf("rm -rf %s && mkdir -p %s && tar -o -C %s --strip-components=1 -xf -", sess.ShellQuote(guestPath), sess.ShellQuote(guestPath), sess.ShellQuote(guestPath))
|
||||||
|
if err := client.StreamTar(ctx, spec.RepoRoot, command, ©Log); err != nil {
|
||||||
|
return sess.FormatStepError("copy full workspace", err, copyLog.String())
|
||||||
|
}
|
||||||
|
var finalizeLog bytes.Buffer
|
||||||
|
if err := client.RunScript(ctx, FinalizeScript(spec, guestPath, mode), &finalizeLog); err != nil {
|
||||||
|
return sess.FormatStepError("finalize full workspace", err, finalizeLog.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case model.WorkspacePrepareModeMetadataOnly, model.WorkspacePrepareModeShallowOverlay:
|
||||||
|
repoCopyDir, cleanup, err := PrepareRepoCopy(ctx, spec)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
var copyLog bytes.Buffer
|
||||||
|
command := fmt.Sprintf("rm -rf %s && mkdir -p %s && tar -o -C %s --strip-components=1 -xf -", sess.ShellQuote(guestPath), sess.ShellQuote(guestPath), sess.ShellQuote(guestPath))
|
||||||
|
if err := client.StreamTar(ctx, repoCopyDir, command, ©Log); err != nil {
|
||||||
|
return sess.FormatStepError("copy guest git metadata", err, copyLog.String())
|
||||||
|
}
|
||||||
|
var scriptLog bytes.Buffer
|
||||||
|
if err := client.RunScript(ctx, FinalizeScript(spec, guestPath, mode), &scriptLog); err != nil {
|
||||||
|
return sess.FormatStepError("prepare guest checkout", err, scriptLog.String())
|
||||||
|
}
|
||||||
|
if mode == model.WorkspacePrepareModeMetadataOnly {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var overlayLog bytes.Buffer
|
||||||
|
command = fmt.Sprintf("tar -o -C %s --strip-components=1 -xf -", sess.ShellQuote(guestPath))
|
||||||
|
if err := client.StreamTarEntries(ctx, spec.RepoRoot, spec.OverlayPaths, command, &overlayLog); err != nil {
|
||||||
|
return sess.FormatStepError("overlay workspace working tree", err, overlayLog.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported workspace mode %q", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinalizeScript returns the bash script run inside the guest after the repo
|
||||||
|
// copy lands: safe.directory, optional cleanup, branch/detached checkout,
|
||||||
|
// and git identity config.
|
||||||
|
func FinalizeScript(spec RepoSpec, guestPath string, mode model.WorkspacePrepareMode) string {
|
||||||
|
var script strings.Builder
|
||||||
|
script.WriteString("set -euo pipefail\n")
|
||||||
|
fmt.Fprintf(&script, "DIR=%s\n", sess.ShellQuote(guestPath))
|
||||||
|
script.WriteString("git config --global --add safe.directory \"$DIR\"\n")
|
||||||
|
if mode != model.WorkspacePrepareModeFullCopy {
|
||||||
|
script.WriteString("find \"$DIR\" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +\n")
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(spec.BranchName) != "":
|
||||||
|
fmt.Fprintf(&script, "git -C \"$DIR\" checkout -B %s %s\n", sess.ShellQuote(spec.BranchName), sess.ShellQuote(spec.BaseCommit))
|
||||||
|
case strings.TrimSpace(spec.CurrentBranch) != "":
|
||||||
|
fmt.Fprintf(&script, "git -C \"$DIR\" checkout -B %s %s\n", sess.ShellQuote(spec.CurrentBranch), sess.ShellQuote(spec.HeadCommit))
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(&script, "git -C \"$DIR\" checkout --detach %s\n", sess.ShellQuote(spec.HeadCommit))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(spec.GitUserName) != "" && strings.TrimSpace(spec.GitUserEmail) != "" {
|
||||||
|
fmt.Fprintf(&script, "git -C \"$DIR\" config user.name %s\n", sess.ShellQuote(spec.GitUserName))
|
||||||
|
fmt.Fprintf(&script, "git -C \"$DIR\" config user.email %s\n", sess.ShellQuote(spec.GitUserEmail))
|
||||||
|
}
|
||||||
|
return script.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareRepoCopy materialises a shallow clone of spec into a temp dir. The
|
||||||
|
// returned cleanup removes the temp root.
|
||||||
|
func PrepareRepoCopy(ctx context.Context, spec RepoSpec) (string, func(), error) {
|
||||||
|
tempRoot, err := os.MkdirTemp("", "banger-workspace-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
cleanup := func() { _ = os.RemoveAll(tempRoot) }
|
||||||
|
repoCopyDir := filepath.Join(tempRoot, spec.RepoName)
|
||||||
|
cloneArgs := []string{"clone", "--no-checkout", "--depth", fmt.Sprintf("%d", ShallowFetchDepth)}
|
||||||
|
if strings.TrimSpace(spec.CurrentBranch) != "" {
|
||||||
|
cloneArgs = append(cloneArgs, "--single-branch", "--branch", spec.CurrentBranch)
|
||||||
|
}
|
||||||
|
cloneArgs = append(cloneArgs, GitFileURL(spec.RepoRoot), repoCopyDir)
|
||||||
|
if err := RunHostCommand(ctx, "git", cloneArgs...); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, fmt.Errorf("clone shallow workspace repo copy: %w", err)
|
||||||
|
}
|
||||||
|
checkoutCommit := spec.HeadCommit
|
||||||
|
if strings.TrimSpace(spec.BranchName) != "" {
|
||||||
|
checkoutCommit = spec.BaseCommit
|
||||||
|
}
|
||||||
|
if err := RunHostCommand(ctx, "git", "-C", repoCopyDir, "cat-file", "-e", checkoutCommit+"^{commit}"); err != nil {
|
||||||
|
if err := RunHostCommand(ctx, "git", "-C", repoCopyDir, "fetch", "--depth", fmt.Sprintf("%d", ShallowFetchDepth), GitFileURL(spec.RepoRoot), checkoutCommit); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, fmt.Errorf("fetch shallow workspace repo commit %s: %w", checkoutCommit, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(spec.OriginURL) != "" {
|
||||||
|
if err := RunHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "set-url", "origin", spec.OriginURL); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, fmt.Errorf("set workspace origin remote: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := RunHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "remove", "origin"); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, fmt.Errorf("remove workspace placeholder origin remote: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repoCopyDir, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveSourcePath expands rawPath to an absolute path and verifies it is
|
||||||
|
// an existing directory.
|
||||||
|
func ResolveSourcePath(rawPath string) (string, error) {
|
||||||
|
if strings.TrimSpace(rawPath) == "" {
|
||||||
|
return "", errors.New("workspace source path is required")
|
||||||
|
}
|
||||||
|
absPath, err := filepath.Abs(rawPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return "", fmt.Errorf("%s is not a directory", absPath)
|
||||||
|
}
|
||||||
|
return absPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSubmodules returns the gitlink paths in repoRoot (mode 160000 entries).
|
||||||
|
func ListSubmodules(ctx context.Context, repoRoot string) ([]string, error) {
|
||||||
|
output, err := GitOutput(ctx, repoRoot, "ls-files", "--stage", "-z")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("inspect workspace git index for %s: %w", repoRoot, err)
|
||||||
|
}
|
||||||
|
var submodules []string
|
||||||
|
for _, record := range ParseNullSeparatedOutput(output) {
|
||||||
|
if !strings.HasPrefix(record, "160000 ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, path, ok := strings.Cut(record, "\t")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
submodules = append(submodules, strings.TrimSpace(path))
|
||||||
|
}
|
||||||
|
sort.Strings(submodules)
|
||||||
|
return submodules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOverlayPaths returns tracked + untracked non-ignored files in
|
||||||
|
// repoRoot. Missing tracked entries (deleted working-tree files) are skipped.
|
||||||
|
func ListOverlayPaths(ctx context.Context, repoRoot string) ([]string, error) {
|
||||||
|
trackedOutput, err := GitOutput(ctx, repoRoot, "ls-files", "-z")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list tracked files for %s: %w", repoRoot, err)
|
||||||
|
}
|
||||||
|
untrackedOutput, err := GitOutput(ctx, repoRoot, "ls-files", "--others", "--exclude-standard", "-z")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list untracked files for %s: %w", repoRoot, err)
|
||||||
|
}
|
||||||
|
paths := make([]string, 0)
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, relPath := range ParseNullSeparatedOutput(trackedOutput) {
|
||||||
|
if relPath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(filepath.Join(repoRoot, relPath)); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
seen[relPath] = struct{}{}
|
||||||
|
paths = append(paths, relPath)
|
||||||
|
}
|
||||||
|
for _, relPath := range ParseNullSeparatedOutput(untrackedOutput) {
|
||||||
|
if relPath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[relPath]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[relPath] = struct{}{}
|
||||||
|
paths = append(paths, relPath)
|
||||||
|
}
|
||||||
|
sort.Strings(paths)
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePrepareMode validates and canonicalises a user-supplied mode value.
|
||||||
|
func ParsePrepareMode(raw string) (model.WorkspacePrepareMode, error) {
|
||||||
|
switch strings.TrimSpace(raw) {
|
||||||
|
case "", string(model.WorkspacePrepareModeShallowOverlay):
|
||||||
|
return model.WorkspacePrepareModeShallowOverlay, nil
|
||||||
|
case string(model.WorkspacePrepareModeFullCopy):
|
||||||
|
return model.WorkspacePrepareModeFullCopy, nil
|
||||||
|
case string(model.WorkspacePrepareModeMetadataOnly):
|
||||||
|
return model.WorkspacePrepareModeMetadataOnly, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported workspace mode %q", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitOutput runs `git [-C dir] args...` and returns its raw stdout.
|
||||||
|
func GitOutput(ctx context.Context, dir string, args ...string) ([]byte, error) {
|
||||||
|
fullArgs := make([]string, 0, len(args)+2)
|
||||||
|
if strings.TrimSpace(dir) != "" {
|
||||||
|
fullArgs = append(fullArgs, "-C", dir)
|
||||||
|
}
|
||||||
|
fullArgs = append(fullArgs, args...)
|
||||||
|
return HostCommandOutputFunc(ctx, "git", fullArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitTrimmedOutput returns GitOutput with surrounding whitespace trimmed.
|
||||||
|
func GitTrimmedOutput(ctx context.Context, dir string, args ...string) (string, error) {
|
||||||
|
output, err := GitOutput(ctx, dir, args...)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(output)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitResolvedConfigValue reads git config key with --default "" --get.
|
||||||
|
func GitResolvedConfigValue(ctx context.Context, dir, key string) (string, error) {
|
||||||
|
return GitTrimmedOutput(ctx, dir, "config", "--default", "", "--get", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseNullSeparatedOutput splits on NULs and trims, returning non-empty
|
||||||
|
// values in order.
|
||||||
|
func ParseNullSeparatedOutput(output []byte) []string {
|
||||||
|
chunks := bytes.Split(output, []byte{0})
|
||||||
|
values := make([]string, 0, len(chunks))
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
value := strings.TrimSpace(string(chunk))
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunHostCommand runs a host command via HostCommandOutputFunc, discarding
|
||||||
|
// its stdout.
|
||||||
|
func RunHostCommand(ctx context.Context, name string, args ...string) error {
|
||||||
|
_, err := HostCommandOutputFunc(ctx, name, args...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitFileURL returns a file:// URL for path, the form git requires when
|
||||||
|
// cloning from a local directory.
|
||||||
|
func GitFileURL(path string) string {
|
||||||
|
return (&url.URL{Scheme: "file", Path: filepath.ToSlash(path)}).String()
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue