github.com/treeverse/lakefs@v1.24.1-0.20240520134607-95648127bfb0/pkg/git/git.go (about)

     1  package git
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"io/fs"
     7  	"os"
     8  	"os/exec"
     9  	"path/filepath"
    10  	"regexp"
    11  	"strings"
    12  	"text/template"
    13  
    14  	"github.com/treeverse/lakefs/pkg/fileutil"
    15  	"golang.org/x/exp/slices"
    16  )
    17  
    18  const (
    19  	IgnoreFile        = ".gitignore"
    20  	IgnoreDefaultMode = 0o644
    21  	NoRemoteRC        = 2
    22  )
    23  
    24  var (
    25  	RemoteRegex     = regexp.MustCompile(`(?P<server>[\w.:]+)[/:](?P<owner>[\w.-]+)/(?P<project>[\w.-]+)\.git$`)
    26  	CommitTemplates = map[string]string{
    27  		"github.com":    "https://github.com/{{ .Owner }}/{{ .Project }}/commit/{{ .Ref }}",
    28  		"gitlab.com":    "https://gitlab.com/{{ .Owner }}/{{ .Project }}/-/commit/{{ .Ref }}",
    29  		"bitbucket.org": "https://bitbucket.org/{{ .Owner }}/{{ .Project }}/commits/{{ .Ref }}",
    30  	}
    31  )
    32  
    33  type URL struct {
    34  	Server  string
    35  	Owner   string
    36  	Project string
    37  }
    38  
    39  func git(dir string, args ...string) (string, int, error) {
    40  	_, err := exec.LookPath("git") // assume git is in the path, otherwise consider as not having git support
    41  	if err != nil {
    42  		return "", 0, ErrNoGit
    43  	}
    44  	cmd := exec.Command("git", args...)
    45  	cmd.Dir = dir
    46  	out, err := cmd.CombinedOutput()
    47  	rc := 0
    48  	if err != nil {
    49  		var exitError *exec.ExitError
    50  		if errors.As(err, &exitError) {
    51  			rc = exitError.ExitCode()
    52  		} else {
    53  			rc = -1
    54  		}
    55  	}
    56  	return string(out), rc, err
    57  }
    58  
    59  // IsRepository Return true if dir is a path to a directory in a git repository, false otherwise
    60  func IsRepository(dir string) bool {
    61  	_, _, err := git(dir, "rev-parse", "--is-inside-work-tree")
    62  	return err == nil
    63  }
    64  
    65  // GetRepositoryPath Returns the git repository root path if dir is a directory inside a git repository, otherwise returns error
    66  func GetRepositoryPath(dir string) (string, error) {
    67  	out, _, err := git(dir, "rev-parse", "--show-toplevel")
    68  	return handleOutput(out, err)
    69  }
    70  
    71  func createEntriesForIgnore(dir string, paths []string, exclude bool) ([]string, error) {
    72  	var entries []string
    73  	for _, p := range paths {
    74  		pathInRepo, err := filepath.Rel(dir, p)
    75  		if err != nil {
    76  			return nil, fmt.Errorf("%s :%w", p, err)
    77  		}
    78  		isDir, err := fileutil.IsDir(p)
    79  		if err != nil && !errors.Is(err, fs.ErrNotExist) {
    80  			return nil, fmt.Errorf("%s :%w", p, err)
    81  		}
    82  		if isDir {
    83  			pathInRepo = filepath.Join(pathInRepo, "*")
    84  		}
    85  		if exclude {
    86  			pathInRepo = "!" + pathInRepo
    87  		}
    88  		entries = append(entries, pathInRepo)
    89  	}
    90  	return entries, nil
    91  }
    92  
    93  // updateIgnoreFileSection updates or inserts a section, identified by a marker, within a file's contents,
    94  // and returns the modified contents as a byte slice. The section begins with "# [marker]" and ends with "# End [marker]".
    95  // It retains existing entries and appends new entries from the provided slice.
    96  func updateIgnoreFileSection(contents []byte, marker string, entries []string) []byte {
    97  	var lines []string
    98  	if len(contents) > 0 {
    99  		lines = strings.Split(string(contents), "\n")
   100  	}
   101  
   102  	// point to the existing section or to the end of the file
   103  	startIdx := slices.IndexFunc(lines, func(s string) bool {
   104  		return strings.HasPrefix(s, "# "+marker)
   105  	})
   106  	var endIdx int
   107  	if startIdx == -1 {
   108  		startIdx = len(lines)
   109  		endIdx = startIdx
   110  	} else {
   111  		endIdx = slices.IndexFunc(lines[startIdx:], func(s string) bool {
   112  			return s == "" || strings.HasPrefix(s, "# End "+marker)
   113  		})
   114  		if endIdx == -1 {
   115  			endIdx = len(lines)
   116  		} else {
   117  			endIdx += startIdx + 1
   118  		}
   119  	}
   120  
   121  	// collect existing entries - entries found in the section that are not commented out
   122  	var existing []string
   123  	for i := startIdx; i < endIdx; i++ {
   124  		if lines[i] == "" ||
   125  			strings.HasPrefix(lines[i], "#") ||
   126  			slices.Contains(entries, lines[i]) {
   127  			continue
   128  		}
   129  		existing = append(existing, lines[i])
   130  	}
   131  
   132  	// delete and insert new content
   133  	lines = slices.Delete(lines, startIdx, endIdx)
   134  	newContent := []string{"# " + marker}
   135  	newContent = append(newContent, existing...)
   136  	newContent = append(newContent, entries...)
   137  	newContent = append(newContent, "# End "+marker)
   138  	lines = slices.Insert(lines, startIdx, newContent...)
   139  
   140  	// join lines make sure content ends with new line
   141  	if lines[len(lines)-1] != "" {
   142  		lines = append(lines, "")
   143  	}
   144  	result := strings.Join(lines, "\n")
   145  	return []byte(result)
   146  }
   147  
   148  // Ignore modify/create .ignore file to include a section headed by the marker string and contains the provided ignore and exclude paths.
   149  // If the section exists, it will append paths to the given section, otherwise writes the section at the end of the file.
   150  // All file paths must be absolute.
   151  // dir is a path in the git repository, if a .gitignore file is not found, a new file will be created in the repository root
   152  func Ignore(dir string, ignorePaths, excludePaths []string, marker string) (string, error) {
   153  	gitDir, err := GetRepositoryPath(dir)
   154  	if err != nil {
   155  		return "", err
   156  	}
   157  
   158  	ignoreEntries, err := createEntriesForIgnore(gitDir, ignorePaths, false)
   159  	if err != nil {
   160  		return "", err
   161  	}
   162  	excludeEntries, err := createEntriesForIgnore(gitDir, excludePaths, true)
   163  	if err != nil {
   164  		return "", err
   165  	}
   166  	ignoreEntries = append(ignoreEntries, excludeEntries...)
   167  
   168  	// read ignore file content
   169  	ignoreFilePath := filepath.Join(gitDir, IgnoreFile)
   170  	ignoreFile, err := os.ReadFile(ignoreFilePath)
   171  	if err != nil && !os.IsNotExist(err) {
   172  		return "", err
   173  	}
   174  
   175  	// get current file mode, if exists
   176  	var mode os.FileMode = IgnoreDefaultMode
   177  	if ignoreFile != nil {
   178  		if info, err := os.Stat(ignoreFilePath); err == nil {
   179  			mode = info.Mode()
   180  		}
   181  	}
   182  
   183  	// update ignore file local section and write back
   184  	ignoreFile = updateIgnoreFileSection(ignoreFile, marker, ignoreEntries)
   185  	if err = os.WriteFile(ignoreFilePath, ignoreFile, mode); err != nil {
   186  		return "", err
   187  	}
   188  
   189  	return ignoreFilePath, nil
   190  }
   191  
   192  func CurrentCommit(path string) (string, error) {
   193  	out, _, err := git(path, "rev-parse", "--short", "HEAD")
   194  	return handleOutput(out, err)
   195  }
   196  
   197  func MetadataFor(path, ref string) (map[string]string, error) {
   198  	kv := make(map[string]string)
   199  	kv["git_commit_id"] = ref
   200  	originURL, err := Origin(path)
   201  	if errors.Is(err, ErrRemoteNotFound) {
   202  		return kv, nil // no additional data to add
   203  	} else if err != nil {
   204  		return kv, err
   205  	}
   206  	parsed := ParseURL(originURL)
   207  	if parsed != nil {
   208  		if tmpl, ok := CommitTemplates[parsed.Server]; ok {
   209  			t := template.Must(template.New("url").Parse(tmpl))
   210  			out := new(strings.Builder)
   211  			_ = t.Execute(out, struct {
   212  				Owner   string
   213  				Project string
   214  				Ref     string
   215  			}{
   216  				Owner:   parsed.Owner,
   217  				Project: parsed.Project,
   218  				Ref:     ref,
   219  			})
   220  			kv[fmt.Sprintf("::lakefs::%s::url[url:ui]", parsed.Server)] = out.String()
   221  		}
   222  	}
   223  	return kv, nil
   224  }
   225  
   226  func Origin(path string) (string, error) {
   227  	out, rc, err := git(path, "remote", "get-url", "origin")
   228  	if rc == NoRemoteRC {
   229  		// from Git's man page:
   230  		// "When subcommands such as add, rename, and remove can’t find the remote in question,
   231  		//	the exit status is 2"
   232  		return "", nil
   233  	}
   234  	return handleOutput(out, err)
   235  }
   236  
   237  func ParseURL(raw string) *URL {
   238  	matches := RemoteRegex.FindStringSubmatch(raw)
   239  	if matches == nil { // TODO niro: How to handle better changes in templates?
   240  		return nil
   241  	}
   242  	return &URL{
   243  		Server:  matches[RemoteRegex.SubexpIndex("server")],
   244  		Owner:   matches[RemoteRegex.SubexpIndex("owner")],
   245  		Project: matches[RemoteRegex.SubexpIndex("project")],
   246  	}
   247  }
   248  
   249  func handleOutput(out string, err error) (string, error) {
   250  	switch {
   251  	case err == nil:
   252  		return strings.TrimSpace(out), nil
   253  	case strings.Contains(out, "not a git repository"):
   254  		return "", ErrNotARepository
   255  	case strings.Contains(out, "remote not found"):
   256  		return "", ErrRemoteNotFound
   257  	default:
   258  		return "", fmt.Errorf("%s: %w", out, err)
   259  	}
   260  }