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 }