pkg/true_git/patch.go

Summary

Maintainability
C
1 day
Test Coverage
D
61%
package true_git

import (
    "context"
    "fmt"
    "io"
    "os"
    "os/exec"
    "path/filepath"
    "strings"
    "sync"

    "github.com/werf/werf/v2/pkg/path_matcher"
    "github.com/werf/werf/v2/pkg/util"
)

type PatchOptions struct {
    PathScope            string // Determines the directory that will get into the result (similar to <pathspec> in the git commands).
    PathMatcher          path_matcher.PathMatcher
    FromCommit, ToCommit string
    FileRenames          map[string]string // Files to rename during patching. Git repo relative paths of original files as keys, new filenames (without base path) as values.

    // TODO: maybe add --path-status option for git, so that created patch will be only presented by the descriptor without content-diff

    WithEntireFileContext bool
    WithBinary            bool
}

func (opts PatchOptions) ID() string {
    var renamedOldFilePaths, renamedNewFileNames []string
    for renamedOldFilePath, renamedNewFileName := range opts.FileRenames {
        renamedOldFilePaths = append(renamedOldFilePaths, renamedOldFilePath)
        renamedNewFileNames = append(renamedNewFileNames, renamedNewFileName)
    }

    return util.Sha256Hash(
        append(
            append(renamedOldFilePaths, renamedNewFileNames...),
            opts.FromCommit,
            opts.ToCommit,
            opts.PathScope,
            opts.PathMatcher.ID(),
            fmt.Sprint(opts.WithBinary),
            fmt.Sprint(opts.WithEntireFileContext),
        )...,
    )
}

type PatchDescriptor struct {
    Paths         []string
    BinaryPaths   []string
    PathsToRemove []string
}

func PatchWithSubmodules(ctx context.Context, out io.Writer, gitDir, workTreeCacheDir string, opts PatchOptions) (*PatchDescriptor, error) {
    var res *PatchDescriptor

    err := withWorkTreeCacheLock(ctx, workTreeCacheDir, func() error {
        writePatchRes, err := writePatch(ctx, out, gitDir, workTreeCacheDir, true, opts)
        res = writePatchRes
        return err
    })

    return res, err
}

func Patch(ctx context.Context, out io.Writer, gitDir string, opts PatchOptions) (*PatchDescriptor, error) {
    return writePatch(ctx, out, gitDir, "", false, opts)
}

func debugPatch() bool {
    return os.Getenv("WERF_TRUE_GIT_DEBUG_PATCH") == "1"
}

func writePatch(ctx context.Context, out io.Writer, gitDir, workTreeCacheDir string, withSubmodules bool, opts PatchOptions) (*PatchDescriptor, error) {
    var err error

    gitDir, err = filepath.Abs(gitDir)
    if err != nil {
        return nil, fmt.Errorf("bad git dir %s: %w", gitDir, err)
    }

    workTreeCacheDir, err = filepath.Abs(workTreeCacheDir)
    if err != nil {
        return nil, fmt.Errorf("bad work tree cache dir %s: %w", workTreeCacheDir, err)
    }

    if withSubmodules && workTreeCacheDir == "" {
        return nil, fmt.Errorf("provide work tree cache directory to enable submodules!")
    }

    commonGitOpts := append(getCommonGitOptions(),
        "-c", "diff.renames=false",
        "-c", "core.quotePath=false",
    )
    if opts.WithEntireFileContext {
        commonGitOpts = append(commonGitOpts, "-c", "diff.context=999999999")
    }

    diffOpts := []string{"--full-index"}
    if withSubmodules {
        diffOpts = append(diffOpts, "--submodule=diff")
    } else {
        diffOpts = append(diffOpts, "--submodule=log")
    }
    if opts.WithBinary {
        diffOpts = append(diffOpts, "--binary")
    }

    var cmd *exec.Cmd

    if withSubmodules {
        workTreeDir, err := prepareWorkTree(ctx, gitDir, workTreeCacheDir, opts.ToCommit, withSubmodules)
        if err != nil {
            return nil, fmt.Errorf("cannot prepare work tree in cache %s for commit %s: %w", workTreeCacheDir, opts.ToCommit, err)
        }

        gitArgs := commonGitOpts
        gitArgs = append(gitArgs, "-C", workTreeDir)
        gitArgs = append(gitArgs, "diff")
        gitArgs = append(gitArgs, diffOpts...)
        gitArgs = append(gitArgs, opts.FromCommit, opts.ToCommit)

        if debugPatch() {
            fmt.Printf("# git %s\n", strings.Join(gitArgs, " "))
        }

        cmd = exec.Command("git", gitArgs...)

        cmd.Dir = workTreeDir // required for `git diff` with submodules
    } else {
        gitArgs := commonGitOpts
        gitArgs = append(gitArgs, "-C", gitDir)
        gitArgs = append(gitArgs, "diff")
        gitArgs = append(gitArgs, diffOpts...)
        gitArgs = append(gitArgs, opts.FromCommit, opts.ToCommit)

        if debugPatch() {
            fmt.Printf("# git %s\n", strings.Join(gitArgs, " "))
        }

        cmd = exec.Command("git", gitArgs...)
    }

    stdoutPipe, err := cmd.StdoutPipe()
    if err != nil {
        return nil, fmt.Errorf("error creating git diff stdout pipe: %w", err)
    }

    stderrPipe, err := cmd.StderrPipe()
    if err != nil {
        return nil, fmt.Errorf("error creating git diff stderr pipe: %w", err)
    }

    if err := cmd.Start(); err != nil {
        return nil, fmt.Errorf("error starting git diff: %w", err)
    }

    outputErrChan := make(chan error)
    stdoutChan := make(chan []byte)
    stderrChan := make(chan []byte)
    doneChan := make(chan bool)

    var wg sync.WaitGroup

    wg.Add(1)
    go func() {
        err := consumePipeOutput(stdoutPipe, func(data []byte) error {
            dataCopy := make([]byte, len(data))
            copy(dataCopy, data)

            stdoutChan <- dataCopy

            return nil
        })
        if err != nil {
            outputErrChan <- err
        }

        wg.Done()
    }()

    wg.Add(1)
    go func() {
        err := consumePipeOutput(stderrPipe, func(data []byte) error {
            dataCopy := make([]byte, len(data))
            copy(dataCopy, data)

            stderrChan <- dataCopy

            return nil
        })
        if err != nil {
            outputErrChan <- err
        }

        wg.Done()
    }()

    go func() {
        wg.Wait()
        doneChan <- true
    }()

    if debugPatch() {
        out = io.MultiWriter(out, os.Stdout)
    }

    p := makeDiffParser(out, opts.PathScope, opts.PathMatcher, opts.FileRenames)

WaitForData:
    for {
        select {
        case err := <-outputErrChan:
            return nil, fmt.Errorf("error getting git diff output: %w\nunrecognized output:\n%s", err, p.UnrecognizedCapture.String())
        case stdoutData := <-stdoutChan:
            if err := p.HandleStdout(stdoutData); err != nil {
                return nil, err
            }
        case stderrData := <-stderrChan:
            if err := p.HandleStderr(stderrData); err != nil {
                return nil, err
            }
        case <-doneChan:
            break WaitForData
        }
    }

    if err := cmd.Wait(); err != nil {
        return nil, fmt.Errorf("git diff error: %w\nunrecognized output:\n%s", err, p.UnrecognizedCapture.String())
    }

    desc := &PatchDescriptor{
        Paths:         p.Paths,
        BinaryPaths:   p.BinaryPaths,
        PathsToRemove: p.PathsToRemove,
    }

    if debugPatch() {
        fmt.Printf("Patch paths count is %d, binary paths count is %d\n", len(desc.Paths), len(desc.BinaryPaths))
        for _, path := range desc.Paths {
            fmt.Printf("Patch path %s\n", path)
        }
        for _, path := range desc.BinaryPaths {
            fmt.Printf("Binary patch path %s\n", path)
        }
    }

    return desc, nil
}

func consumePipeOutput(pipe io.ReadCloser, handleChunk func(data []byte) error) error {
    chunkBuf := make([]byte, 1024*64)

    for {
        n, err := pipe.Read(chunkBuf)
        if n > 0 {
            handleErr := handleChunk(chunkBuf[:n])
            if handleErr != nil {
                return handleErr
            }
        }

        if err == io.EOF {
            err := pipe.Close()
            if err != nil {
                return fmt.Errorf("error closing pipe: %w", err)
            }

            return nil
        }

        if err != nil {
            return fmt.Errorf("error reading pipe: %w", err)
        }
    }
}