package commands
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"os"
"path"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/git-lfs/git-lfs/v3/git"
"github.com/git-lfs/git-lfs/v3/git/gitattr"
"github.com/git-lfs/git-lfs/v3/tools"
"github.com/git-lfs/git-lfs/v3/tr"
"github.com/spf13/cobra"
)
var (
prefixBlocklist = []string{
".git", ".lfs",
}
trackLockableFlag bool
trackNotLockableFlag bool
trackVerboseLoggingFlag bool
trackDryRunFlag bool
trackNoModifyAttrsFlag bool
trackNoExcludedFlag bool
trackFilenameFlag bool
trackJSONFlag bool
)
func trackCommand(cmd *cobra.Command, args []string) {
requireGitVersion()
setupWorkingCopy()
if trackDryRunFlag {
trackNoModifyAttrsFlag = true
}
if !cfg.Os.Bool("GIT_LFS_TRACK_NO_INSTALL_HOOKS", false) {
installHooks(false)
}
if len(args) == 0 {
listPatterns()
return
}
if trackJSONFlag {
Exit(tr.Tr.Get("--json option can't be combined with arguments"))
}
mp := gitattr.NewMacroProcessor()
git.GetSystemAttributePaths(mp, cfg.Os)
git.GetRootAttributePaths(mp, cfg.Git)
knownPatterns := git.GetAttributePaths(mp, cfg.LocalWorkingDir(), cfg.LocalGitDir())
lineEnd := getAttributeLineEnding(knownPatterns)
if len(lineEnd) == 0 {
lineEnd = gitLineEnding(cfg.Git)
}
wd, _ := tools.Getwd()
wd = tools.ResolveSymlinks(wd)
relpath, err := filepath.Rel(cfg.LocalWorkingDir(), wd)
if err != nil {
Exit(tr.Tr.Get("Current directory %q outside of Git working directory %q.", wd, cfg.LocalWorkingDir()))
}
changedAttribLines := make(map[string]string)
var readOnlyPatterns []string
var writeablePatterns []string
ArgsLoop:
for _, unsanitizedPattern := range args {
pattern := tools.TrimCurrentPrefix(cleanRootPath(unsanitizedPattern))
var encodedArg string
if trackFilenameFlag {
encodedArg = escapeGlobCharacters(pattern)
pattern = escapeGlobCharacters(pattern)
} else {
encodedArg = escapeAttrPattern(pattern)
}
if !trackNoModifyAttrsFlag {
for _, known := range knownPatterns {
if unescapeAttrPattern(known.Path) == path.Join(relpath, pattern) &&
((trackLockableFlag && known.Lockable) ||
(trackNotLockableFlag && !known.Lockable) ||
(!trackLockableFlag && !trackNotLockableFlag)) {
Print(tr.Tr.Get("%q already supported", pattern))
continue ArgsLoop
}
}
}
lockableArg := ""
if trackLockableFlag {
lockableArg = " " + git.LockableAttrib
}
changedAttribLines[pattern] = fmt.Sprintf("%s filter=lfs diff=lfs merge=lfs -text%v%s", encodedArg, lockableArg, lineEnd)
if trackLockableFlag {
readOnlyPatterns = append(readOnlyPatterns, pattern)
} else {
writeablePatterns = append(writeablePatterns, pattern)
}
Print(tr.Tr.Get("Tracking %q", unescapeAttrPattern(encodedArg)))
}
var (
attribContents []byte
attributesFile *os.File
)
if !trackNoModifyAttrsFlag {
attribContents, err = os.ReadFile(".gitattributes")
if err != nil && !os.IsNotExist(err) {
Print(tr.Tr.Get("Error reading '.gitattributes' file"))
return
}
attributesFile, err = os.OpenFile(".gitattributes", os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0660)
if err != nil {
Print(tr.Tr.Get("Error opening '.gitattributes' file"))
return
}
defer attributesFile.Close()
if len(attribContents) > 0 {
scanner := bufio.NewScanner(bytes.NewReader(attribContents))
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 1 {
continue
}
pattern := unescapeAttrPattern(fields[0])
if newline, ok := changedAttribLines[pattern]; ok {
attributesFile.WriteString(newline)
delete(changedAttribLines, pattern)
} else {
attributesFile.WriteString(line + lineEnd)
}
}
}
}
modified := false
sawError := false
for pattern, newline := range changedAttribLines {
if trackVerboseLoggingFlag {
Print(tr.Tr.Get("Searching for files matching pattern: %s", pattern))
}
gittracked, err := git.GetTrackedFiles(pattern)
if err != nil {
Exit(tr.Tr.Get("Error getting tracked files for %q: %s", pattern, err))
}
if trackVerboseLoggingFlag {
Print(tr.Tr.Get("Found %d files previously added to Git matching pattern: %s", len(gittracked), pattern))
}
var matchedBlocklist bool
for _, f := range gittracked {
if forbidden := blocklistItem(f); forbidden != "" {
Print(tr.Tr.Get("Pattern '%s' matches forbidden file '%s'. If you would like to track %s, modify '.gitattributes' manually.", pattern, f, f))
matchedBlocklist = true
}
}
if matchedBlocklist {
continue
}
if !trackNoModifyAttrsFlag {
attributesFile.WriteString(newline)
}
modified = true
for _, f := range gittracked {
if trackVerboseLoggingFlag || trackDryRunFlag {
Print(tr.Tr.Get("Touching %q", f))
}
if !trackDryRunFlag {
now := time.Now()
err := os.Chtimes(f, now, now)
if err != nil {
LoggedError(err, tr.Tr.Get("Error marking %q modified: %s", f, err))
sawError = true
continue
}
}
}
}
lockClient := newLockClient()
err = lockClient.FixFileWriteFlagsInDir(relpath, readOnlyPatterns, writeablePatterns)
if err != nil {
LoggedError(err, tr.Tr.Get("Error changing lockable file permissions: %s", err))
sawError = true
}
if sawError {
os.Exit(2)
}
if !modified && len(changedAttribLines) > 0 {
os.Exit(1)
}
}
type PatternData struct {
Pattern string `json:"pattern"`
Source string `json:"source"`
Lockable bool `json:"lockable"`
Tracked bool `json:"tracked"`
}
func listPatterns() {
knownPatterns, err := getAllKnownPatterns()
if err != nil {
Exit("unable to list patterns: %s", err)
}
if trackJSONFlag {
patterns := struct {
Patterns []PatternData `json:"patterns"`
}{Patterns: make([]PatternData, 0, len(knownPatterns))}
for _, p := range knownPatterns {
patterns.Patterns = append(patterns.Patterns, PatternData{
Pattern: p.Path,
Source: p.Source.String(),
Tracked: p.Tracked,
Lockable: p.Lockable,
})
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
err := encoder.Encode(patterns)
if err != nil {
ExitWithError(err)
}
return
}
if len(knownPatterns) < 1 {
return
}
Print(tr.Tr.Get("Listing tracked patterns"))
for _, t := range knownPatterns {
if t.Lockable {
Print(tr.Tr.Get(" %s [lockable] (%s)", t.Path, t.Source))
} else if t.Tracked {
Print(" %s (%s)", t.Path, t.Source)
}
}
if trackNoExcludedFlag {
return
}
Print(tr.Tr.Get("Listing excluded patterns"))
for _, t := range knownPatterns {
if !t.Tracked && !t.Lockable {
Print(" %s (%s)", t.Path, t.Source)
}
}
}
func getAllKnownPatterns() ([]git.AttributePath, error) {
mp := gitattr.NewMacroProcessor()
systemPatterns, err := git.GetSystemAttributePaths(mp, cfg.Os)
if err != nil {
return nil, err
}
globalPatterns := git.GetRootAttributePaths(mp, cfg.Git)
knownPatterns := git.GetAttributePaths(mp, cfg.LocalWorkingDir(), cfg.LocalGitDir())
knownPatterns = append(knownPatterns, globalPatterns...)
knownPatterns = append(knownPatterns, systemPatterns...)
return knownPatterns, nil
}
func getAttributeLineEnding(attribs []git.AttributePath) string {
for _, a := range attribs {
if a.Source.Path == ".gitattributes" {
return a.Source.LineEnding
}
}
return ""
}
func blocklistItem(name string) string {
base := filepath.Base(name)
for _, p := range prefixBlocklist {
if strings.HasPrefix(base, p) {
return p
}
}
return ""
}
var (
trackEscapePatterns = map[string]string{
" ": "[[:space:]]",
"#": "\\#",
}
trackEscapeStrings = []string{"*", "[", "]", "?"}
)
func escapeGlobCharacters(s string) string {
var escaped string
if runtime.GOOS == "windows" {
escaped = strings.Replace(s, `\`, "/", -1)
} else {
escaped = strings.Replace(s, `\`, `\\`, -1)
}
for _, ch := range trackEscapeStrings {
escaped = strings.Replace(escaped, ch, fmt.Sprintf("\\%s", ch), -1)
}
for from, to := range trackEscapePatterns {
escaped = strings.Replace(escaped, from, to, -1)
}
return escaped
}
func escapeAttrPattern(s string) string {
var escaped string
if runtime.GOOS == "windows" {
escaped = strings.Replace(s, `\`, "/", -1)
} else {
escaped = strings.Replace(s, `\`, `\\`, -1)
}
for from, to := range trackEscapePatterns {
escaped = strings.Replace(escaped, from, to, -1)
}
return escaped
}
func unescapeAttrPattern(escaped string) string {
var unescaped string = escaped
for to, from := range trackEscapePatterns {
unescaped = strings.Replace(unescaped, from, to, -1)
}
if runtime.GOOS != "windows" {
unescaped = strings.Replace(unescaped, `\\`, `\`, -1)
}
return unescaped
}
func init() {
RegisterCommand("track", trackCommand, func(cmd *cobra.Command) {
cmd.Flags().BoolVarP(&trackLockableFlag, "lockable", "l", false, "make pattern lockable, i.e. read-only unless locked")
cmd.Flags().BoolVarP(&trackNotLockableFlag, "not-lockable", "", false, "remove lockable attribute from pattern")
cmd.Flags().BoolVarP(&trackVerboseLoggingFlag, "verbose", "v", false, "log which files are being tracked and modified")
cmd.Flags().BoolVarP(&trackDryRunFlag, "dry-run", "d", false, "preview results of running `git lfs track`")
cmd.Flags().BoolVarP(&trackNoModifyAttrsFlag, "no-modify-attrs", "", false, "skip modifying .gitattributes file")
cmd.Flags().BoolVarP(&trackNoExcludedFlag, "no-excluded", "", false, "skip listing excluded paths")
cmd.Flags().BoolVarP(&trackFilenameFlag, "filename", "", false, "treat this pattern as a literal filename")
cmd.Flags().BoolVarP(&trackJSONFlag, "json", "", false, "print output in JSON")
})
}