diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 000000000..7bd42d70c --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: 1.20 + cache: true + + - name: Check out code + uses: actions/checkout@v3 + + - name: Run tests + run: go test -v ./... + + - name: Build project + run: make build diff --git a/internal/pkg/patch/patch.go b/internal/pkg/patch/patch.go index dc516847c..7d8c1d562 100644 --- a/internal/pkg/patch/patch.go +++ b/internal/pkg/patch/patch.go @@ -1,19 +1,36 @@ package patch import ( + "bufio" "fmt" "os" "os/exec" + "path/filepath" + "regexp" + "strings" "github.com/devstream-io/devstream/internal/log" ) +const ( + processOptionTabToSpace ProcessOption = "tabToSpace" + processOptionSpaceToTab ProcessOption = "spaceToTab" +) + +type ProcessOption string + // Patch calls the patch command to apply a diff file to an original func Patch(workDir, patchFile string) error { log.Infof("Patching file: %s", patchFile) + // Fix patch file if it mixed tab and space indentation + err := fixPatchFile(workDir, patchFile) + if err != nil { + return fmt.Errorf("patch file fix failed: %w", err) + } + // Check if the patch command exists and is executable - err := checkPatchCommand() + err = checkPatchCommand() if err != nil { return fmt.Errorf("patch command check failed: %w", err) } @@ -50,3 +67,135 @@ func checkPatchCommand() error { return nil } + +// fixPatchFile fixes the patch file if it mixed tab and space indentation. +// The patch file is generated by GPT4, and it may have different indentation with the original file. +// The original file path is contained in the patch file, so we can use the fix the patch file by using the original file. +// If the original file uses tab indentation, we replace all spaces with tabs in the patch file. +// If the original file uses space indentation, we replace all tabs with spaces in the patch file. +func fixPatchFile(workDir, patchFile string) error { + // Read the original file path from the patch file + originalFilePath, err := extractOriginalFilePathFromPatchFile(patchFile) + originalFilePath = filepath.Join(workDir, originalFilePath) + + if err != nil { + return fmt.Errorf("failed to extract original file path from patch string: %w", err) + } + + // Check if the original file contain tabs in the indentation + original, err := os.Open(originalFilePath) + if err != nil { + return fmt.Errorf("failed to open original file: %w", err) + } + defer original.Close() + + hasTab := false + scanner := bufio.NewScanner(original) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "\t") { + hasTab = true + break + } + } + + if err = scanner.Err(); err != nil { + return fmt.Errorf("failed to read original file: %w", err) + } + + // The original file uses tab indentation + if hasTab { + // Replace all space indentation with tabs in the patch file + if err = processTabSpaceSwitch(patchFile, processOptionSpaceToTab); err != nil { + return fmt.Errorf("failed to process tab to space: %w", err) + } + // The original file uses space indentation + } else { + // Replace all tab indentation with spaces in the patch file + if err = processTabSpaceSwitch(patchFile, processOptionTabToSpace); err != nil { + return fmt.Errorf("failed to process space to tab: %w", err) + } + } + + return nil + +} + +// ExtractOriginalFilePathFromPatchString extracts the original file path from a patch string +// e.g. --- pkg/patch/patch.go 2021-08-15 16:00:00.000000000 +0900 -> pkg/patch/patch.go +func extractOriginalFilePathFromPatchFile(patchFile string) (string, error) { + // Read content from the patch file + fileContent, err := os.ReadFile(patchFile) + if err != nil { + return "", fmt.Errorf("failed to read patch file: %w", err) + } + + lines := strings.Split(string(fileContent), "\n") + + for _, line := range lines { + if strings.HasPrefix(line, "--- ") { + fields := strings.Fields(line) + if len(fields) > 1 { + return fields[1], nil + } + } + } + + return "", fmt.Errorf("original file path not found in patch string") +} + +// processTabSpaceSwitch processes the tab/space indentation switch in a file +// If the option is processOptionTabToSpace, it replaces all tabs with spaces +// If the option is processOptionSpaceToTab, it replaces all spaces with tabs +func processTabSpaceSwitch(filePath string, option ProcessOption) error { + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var processedLines []string + + // Matches the start of the string (^) followed by an optional + or - sign, followed by one or more groups of 4 spaces ( {4})+ + spaceRegex := regexp.MustCompile(`^(\+|\-)?( {4})+`) + // Matches the start of the string (^) followed by an optional + or - sign, followed by one or more tabs (\t)+ + tabRegex := regexp.MustCompile(`^(\+|\-)?\t+`) + + for scanner.Scan() { + line := scanner.Text() + if option == processOptionTabToSpace { + line = tabRegex.ReplaceAllStringFunc(line, func(s string) string { + prefix := "" + if s[0] == '+' || s[0] == '-' { + prefix = string(s[0]) + s = s[1:] + } + return prefix + strings.Repeat(" ", len(s)) + }) + } else if option == processOptionSpaceToTab { + line = spaceRegex.ReplaceAllStringFunc(line, func(s string) string { + prefix := "" + if s[0] == '+' || s[0] == '-' { + prefix = string(s[0]) + s = s[1:] + } + return prefix + strings.Repeat("\t", len(s)/4) + }) + } else { + return fmt.Errorf("invalid process option: %s", option) + } + processedLines = append(processedLines, line) + } + + if err = scanner.Err(); err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + err = os.WriteFile(filePath, []byte(strings.Join(processedLines, "\n")+"\n"), 0644) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} diff --git a/internal/pkg/patch/patch_test.go b/internal/pkg/patch/patch_test.go index a39503483..498abd72d 100644 --- a/internal/pkg/patch/patch_test.go +++ b/internal/pkg/patch/patch_test.go @@ -72,32 +72,88 @@ This is the original file. expectedPatchedContent := `Hello, world! This is the patched file. ` - Expect(string(patchedContent)).To(Equal(expectedPatchedContent)) + patchedContentStr := string(patchedContent) + Expect(patchedContentStr).To(Equal(expectedPatchedContent)) }) - It("returns an error if the patch command is not found or not executable", func() { - // Temporarily change PATH to exclude the real patch command - originalPath := os.Getenv("PATH") - err := os.Setenv("PATH", tempDir) + It("returns an error if the patch file is invalid", func() { + originalContent := `Hello, world! +This is the original file. +` + + err := os.WriteFile(originalFile.Name(), []byte(originalContent), 0644) + Expect(err).NotTo(HaveOccurred()) + + invalidPatchContent := fmt.Sprintf(`--- %s ++++ new-file +@@ -1,2 +1,2 @@ +`, + filepath.Base(originalFile.Name())) + + err = os.WriteFile(patchFile.Name(), []byte(invalidPatchContent), 0644) Expect(err).NotTo(HaveOccurred()) - defer func() { - err := os.Setenv("PATH", originalPath) - Expect(err).NotTo(HaveOccurred()) - }() err = Patch(tempDir, patchFile.Name()) Expect(err).To(HaveOccurred()) - Expect(strings.Contains(err.Error(), "patch command not found")).To(BeTrue()) + Expect(strings.Contains(err.Error(), "patch command failed")).To(BeTrue()) }) + }) - It("returns an error if the patch file is invalid", func() { - invalidPatchContent := `This is not a valid patch file.` - err := os.WriteFile(patchFile.Name(), []byte(invalidPatchContent), 0644) + Context("when patching a file with inconsistent indentation", func() { + It("successfully applies the patch with spaces to the original file with tabs", func() { + originalContent := "Hello, world!\n\tThis is the original file with tabs.\n" + + err := os.WriteFile(originalFile.Name(), []byte(originalContent), 0644) + Expect(err).NotTo(HaveOccurred()) + + patchContent := fmt.Sprintf(`--- %s ++++ new-file +@@ -1,2 +1,2 @@ + Hello, world! +- This is the original file with tabs. ++ This is the patched file with tabs. +`, + filepath.Base(originalFile.Name())) + + err = os.WriteFile(patchFile.Name(), []byte(patchContent), 0644) Expect(err).NotTo(HaveOccurred()) err = Patch(tempDir, patchFile.Name()) - Expect(err).To(HaveOccurred()) - Expect(strings.Contains(err.Error(), "patch command failed")).To(BeTrue()) + Expect(err).NotTo(HaveOccurred()) + + patchedContent, err := os.ReadFile(originalFile.Name()) + Expect(err).NotTo(HaveOccurred()) + + expectedPatchedContent := "Hello, world!\n\tThis is the patched file with tabs.\n" + Expect(string(patchedContent)).To(Equal(expectedPatchedContent)) + }) + + It("successfully applies the patch with tabs to the original file with spaces", func() { + originalContent := "Hello, world!\n This is the original file with spaces.\n" + + err := os.WriteFile(originalFile.Name(), []byte(originalContent), 0644) + Expect(err).NotTo(HaveOccurred()) + + patchContent := fmt.Sprintf(`--- %s ++++ new-file +@@ -1,2 +1,2 @@ + Hello, world! +- This is the original file with spaces. ++ This is the patched file with spaces. +`, + filepath.Base(originalFile.Name())) + + err = os.WriteFile(patchFile.Name(), []byte(patchContent), 0644) + Expect(err).NotTo(HaveOccurred()) + + err = Patch(tempDir, patchFile.Name()) + Expect(err).NotTo(HaveOccurred()) + + patchedContent, err := os.ReadFile(originalFile.Name()) + Expect(err).NotTo(HaveOccurred()) + + expectedPatchedContent := "Hello, world!\n This is the patched file with spaces.\n" + Expect(string(patchedContent)).To(Equal(expectedPatchedContent)) }) }) })