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 }