github.com/knoebber/dotfile@v1.0.6/local/storage.go (about)

     1  package local
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"fmt"
     7  	"os"
     8  	"path/filepath"
     9  	"strings"
    10  
    11  	"github.com/knoebber/dotfile/dotfile"
    12  	"github.com/knoebber/dotfile/dotfileclient"
    13  	"github.com/knoebber/usererror"
    14  	"github.com/pkg/errors"
    15  )
    16  
    17  var (
    18  	// ErrNotTracked is returned when the current alias in storage is not tracked.
    19  	ErrNotTracked = errors.New("file not tracked")
    20  	// ErrNoData is returned when a method expects non nil file data.
    21  	ErrNoData = errors.New("tracking data not loaded")
    22  )
    23  
    24  // Storage provides methods for manipulating tracked files on the file system.
    25  type Storage struct {
    26  	Alias    string                // The name of the file that is being tracked.
    27  	Dir      string                // The path to the folder where data will be stored.
    28  	FileData *dotfile.TrackingData // The current file that storage is tracking.
    29  }
    30  
    31  func (s *Storage) jsonPath() string {
    32  	return filepath.Join(s.Dir, s.Alias+".json")
    33  }
    34  
    35  func (s *Storage) hasSavedData() bool {
    36  	return exists(s.jsonPath())
    37  }
    38  
    39  // JSON returns the tracked files json.
    40  func (s *Storage) JSON() ([]byte, error) {
    41  	jsonContent, err := os.ReadFile(s.jsonPath())
    42  	if errors.Is(err, os.ErrNotExist) {
    43  		return nil, ErrNotTracked
    44  	} else if err != nil {
    45  		return nil, errors.Wrap(err, "reading tracking data")
    46  	}
    47  
    48  	return jsonContent, nil
    49  }
    50  
    51  // SetTrackingData reads the tracking data from the filesystem into FileData.
    52  func (s *Storage) SetTrackingData() error {
    53  	if s.Alias == "" {
    54  		return errors.New("cannot set tracking data: alias is empty")
    55  	}
    56  	if s.Dir == "" {
    57  		return errors.New("cannot set tracking data: dir is empty")
    58  	}
    59  
    60  	s.FileData = new(dotfile.TrackingData)
    61  
    62  	jsonContent, err := s.JSON()
    63  	if err != nil {
    64  		return err
    65  	}
    66  
    67  	if err = json.Unmarshal(jsonContent, s.FileData); err != nil {
    68  		return errors.Wrapf(err, "unmarshaling tracking data")
    69  	}
    70  
    71  	return nil
    72  }
    73  
    74  func (s *Storage) save() error {
    75  	content, err := json.MarshalIndent(s.FileData, "", jsonIndent)
    76  	if err != nil {
    77  		return errors.Wrap(err, "marshalling tracking data to json")
    78  	}
    79  
    80  	// Create the storage directory if it does not yet exist.
    81  	// Example: ~/.local/share/dotfile
    82  	if err := createDir(s.Dir); err != nil {
    83  		return err
    84  	}
    85  
    86  	// Example: ~/.local/share/dotfile/bash_profile.json
    87  	if err := os.WriteFile(s.jsonPath(), content, 0644); err != nil {
    88  		return errors.Wrap(err, "saving tracking data")
    89  	}
    90  
    91  	return nil
    92  }
    93  
    94  // HasCommit return whether the file has a commit with hash.
    95  func (s *Storage) HasCommit(hash string) (exists bool, err error) {
    96  	if s.FileData == nil {
    97  		return false, ErrNoData
    98  	}
    99  
   100  	for _, c := range s.FileData.Commits {
   101  		if c.Hash == hash {
   102  			return true, nil
   103  		}
   104  	}
   105  	return
   106  }
   107  
   108  // Revision returns the files state at hash.
   109  func (s *Storage) Revision(hash string) ([]byte, error) {
   110  	revisionPath := filepath.Join(s.Dir, s.Alias, hash)
   111  
   112  	content, err := os.ReadFile(revisionPath)
   113  	if err != nil {
   114  		return nil, errors.Wrapf(err, "reading revision %q", hash)
   115  	}
   116  
   117  	return content, nil
   118  }
   119  
   120  // DirtyContent reads the current content of the tracked file.
   121  // Returns nil when the file no longer exists.
   122  func (s *Storage) DirtyContent() ([]byte, error) {
   123  	path, err := s.Path()
   124  	if err != nil {
   125  		return nil, err
   126  	}
   127  
   128  	result, err := os.ReadFile(path)
   129  	if os.IsNotExist(err) {
   130  		return nil, nil
   131  	}
   132  
   133  	if err != nil {
   134  		return nil, errors.Wrapf(err, "reading %q", s.Alias)
   135  	}
   136  
   137  	return result, nil
   138  }
   139  
   140  // SaveCommit saves a commit to the file system.
   141  // Creates a new directory when its the first commit.
   142  // Updates the file's revision field to point to the new hash.
   143  func (s *Storage) SaveCommit(buff *bytes.Buffer, c *dotfile.Commit) error {
   144  	if s.FileData == nil {
   145  		return ErrNoData
   146  	}
   147  
   148  	s.FileData.Commits = append(s.FileData.Commits, *c)
   149  	if err := writeCommit(buff.Bytes(), s.Dir, s.Alias, c.Hash); err != nil {
   150  		return err
   151  	}
   152  
   153  	s.FileData.Revision = c.Hash
   154  	return s.save()
   155  }
   156  
   157  // Revert writes files with buff and sets it current revision to hash.
   158  func (s *Storage) Revert(buff *bytes.Buffer, hash string) error {
   159  	path, err := s.Path()
   160  	if err != nil {
   161  		return err
   162  	}
   163  
   164  	if err := createDirectories(path); err != nil {
   165  		return err
   166  	}
   167  
   168  	err = os.WriteFile(path, buff.Bytes(), 0644)
   169  	if err != nil {
   170  		return errors.Wrapf(err, "reverting file %q", s.Alias)
   171  	}
   172  
   173  	s.FileData.Revision = hash
   174  	return s.save()
   175  }
   176  
   177  // Path gets the full path to the file.
   178  // Utilizes $HOME to convert paths with ~ to absolute.
   179  func (s *Storage) Path() (string, error) {
   180  	if s.FileData == nil {
   181  		return "", ErrNoData
   182  	}
   183  	if s.FileData.Path == "" {
   184  		return "", errors.New("file data is missing path")
   185  	}
   186  
   187  	// If the saved path is absolute return it.
   188  	if filepath.IsAbs(s.FileData.Path) {
   189  		return s.FileData.Path, nil
   190  	}
   191  
   192  	home, err := os.UserHomeDir()
   193  	if err != nil {
   194  		return "", err
   195  	}
   196  
   197  	return strings.Replace(s.FileData.Path, "~", home, 1), nil
   198  }
   199  
   200  // Push pushes a file's commits to a remote dotfile server.
   201  // Updates the remote file with the new content from local.
   202  func (s *Storage) Push(client *dotfileclient.Client) error {
   203  	var newHashes []string
   204  
   205  	if s.FileData == nil {
   206  		return ErrNoData
   207  	}
   208  
   209  	remoteData, err := client.TrackingData(s.Alias)
   210  	if err != nil {
   211  		return err
   212  	}
   213  
   214  	if remoteData == nil {
   215  		// File isn't yet tracked on remote, push all local revisions.
   216  		for _, c := range s.FileData.Commits {
   217  			newHashes = append(newHashes, c.Hash)
   218  		}
   219  	} else {
   220  		s.FileData, newHashes, err = dotfile.MergeTrackingData(remoteData, s.FileData)
   221  		if err != nil {
   222  			return err
   223  		}
   224  	}
   225  	revisions := make([]*dotfileclient.Revision, len(newHashes))
   226  
   227  	for i, hash := range newHashes {
   228  		revision, err := s.Revision(hash)
   229  		if err != nil {
   230  			return err
   231  		}
   232  
   233  		revisions[i] = &dotfileclient.Revision{
   234  			Bytes: revision,
   235  			Hash:  hash,
   236  		}
   237  	}
   238  
   239  	if err := client.UploadRevisions(s.Alias, s.FileData, revisions); err != nil {
   240  		return err
   241  	}
   242  
   243  	return nil
   244  }
   245  
   246  // Pull retrieves a file's commits from a dotfile server.
   247  // Updates the local file with the new content from remote.
   248  // FileData does not need to be set; its possible to pull a file that does not yet exist.
   249  func (s *Storage) Pull(client *dotfileclient.Client) error {
   250  	var newHashes []string
   251  
   252  	hasSavedData := s.hasSavedData()
   253  
   254  	if hasSavedData {
   255  		if err := s.SetTrackingData(); err != nil {
   256  			return err
   257  		}
   258  
   259  		clean, err := dotfile.IsClean(s, s.FileData.Revision)
   260  		if err != nil {
   261  			return err
   262  		}
   263  
   264  		if !clean {
   265  			return usererror.New("file has uncommitted changes")
   266  		}
   267  	}
   268  
   269  	remoteData, err := client.TrackingData(s.Alias)
   270  	if err != nil {
   271  		return err
   272  	}
   273  	if remoteData == nil {
   274  		return fmt.Errorf("%q not found on remote %q", s.Alias, client.Remote)
   275  	}
   276  
   277  	s.FileData, newHashes, err = dotfile.MergeTrackingData(s.FileData, remoteData)
   278  	if err != nil {
   279  		return err
   280  	}
   281  
   282  	path, err := s.Path()
   283  	if err != nil {
   284  		return err
   285  	}
   286  
   287  	// If the pulled file is new and a file with the remotes path already exists.
   288  	if exists(path) && !hasSavedData {
   289  		return usererror.New(remoteData.Path +
   290  			" already exists and is not tracked by dotfile (remove the file or initialize it before pulling)")
   291  	}
   292  
   293  	fmt.Printf("pulling %d new revisions for %s\n", len(newHashes), s.FileData.Path)
   294  
   295  	revisions, err := client.Revisions(s.Alias, newHashes)
   296  	if err != nil {
   297  		return err
   298  	}
   299  
   300  	for _, revision := range revisions {
   301  		if err = writeCommit(revision.Bytes, s.Dir, s.Alias, revision.Hash); err != nil {
   302  			return err
   303  		}
   304  	}
   305  
   306  	return dotfile.Checkout(s, s.FileData.Revision)
   307  }
   308  
   309  // Move moves the file currently tracked by storage.
   310  func (s *Storage) Move(newPath string, parentDirs bool) error {
   311  	currentPath, err := s.Path()
   312  	if err != nil {
   313  		return err
   314  	}
   315  
   316  	if parentDirs {
   317  		if err := createDirectories(newPath); err != nil {
   318  			return err
   319  		}
   320  	}
   321  
   322  	if err := os.Rename(currentPath, newPath); err != nil {
   323  		return err
   324  	}
   325  
   326  	s.FileData.Path, err = convertPath(newPath)
   327  	if err != nil {
   328  		return err
   329  	}
   330  
   331  	return s.save()
   332  }
   333  
   334  // Rename changes a files alias.
   335  func (s *Storage) Rename(newAlias string) error {
   336  	if err := dotfile.CheckAlias(newAlias); err != nil {
   337  		return err
   338  	}
   339  
   340  	newDir := filepath.Join(s.Dir, newAlias)
   341  	if exists(newDir) {
   342  		return usererror.New(fmt.Sprintf("%q already exists", newAlias))
   343  	}
   344  
   345  	err := os.Rename(filepath.Join(s.Dir, s.Alias), newDir)
   346  	if err != nil {
   347  		return err
   348  	}
   349  
   350  	jsonPath := s.jsonPath()
   351  	s.Alias = newAlias
   352  
   353  	err = os.Rename(jsonPath, s.jsonPath())
   354  	if err != nil {
   355  		return err
   356  	}
   357  
   358  	return nil
   359  }
   360  
   361  // Forget removes all tracking information for alias.
   362  func (s *Storage) Forget() error {
   363  	if err := os.Remove(s.jsonPath()); err != nil {
   364  		return err
   365  	}
   366  
   367  	return os.RemoveAll(filepath.Join(s.Dir, s.Alias))
   368  }
   369  
   370  // RemoveCommits removes all commits except for the current.
   371  func (s *Storage) RemoveCommits() error {
   372  	var current dotfile.Commit
   373  
   374  	if s.FileData == nil {
   375  		return ErrNoData
   376  	}
   377  
   378  	for _, c := range s.FileData.Commits {
   379  		if c.Hash == s.FileData.Revision {
   380  			current = c
   381  			continue
   382  		}
   383  		if err := os.Remove(filepath.Join(s.Dir, s.Alias, c.Hash)); err != nil {
   384  			return err
   385  		}
   386  	}
   387  
   388  	if current.Hash != "" {
   389  		s.FileData.Commits = []dotfile.Commit{current}
   390  		return s.save()
   391  	}
   392  
   393  	return nil
   394  }
   395  
   396  // Remove deletes the file that is tracked and all its data.
   397  func (s *Storage) Remove() error {
   398  	path, err := s.Path()
   399  	if err != nil {
   400  		return err
   401  	}
   402  
   403  	if err = os.Remove(path); err != nil {
   404  		return err
   405  	}
   406  
   407  	return s.Forget()
   408  }