diff --git a/README.md b/README.md index c1fd925..9b94b8a 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,11 @@ be run on a cron. - 🔍 **Smart Scanning**: Automatically discover and import `.env` files in your home directory. - ✨ **Interactive CLI**: User-friendly prompts for file selection and management. +- 🗂️ **Rename Detection**: Automatically finds and updates renamed/moved +repositories. ## TODOS - -- [ ] 🗂️ **Rename Detection**: Automatically handle renamed repositories. +- [x] Rename Detection: automatically update moved files. - [ ] Allow use of keys from `ssh-agent` - [x] Allow configuration of ssh key. - [x] Allow multiple ssh keys. diff --git a/app/config.go b/app/config.go index b2a9cfd..ce890d5 100644 --- a/app/config.go +++ b/app/config.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "path" "path/filepath" "strings" @@ -235,3 +236,32 @@ func (s SshKeyPair) recipient() (age.Recipient, error) { return id, nil } + +// Use fd to find all git roots in the config's search paths +func (c Config) findGitRoots() (paths []string, err error) { + searchPaths, err := c.searchPaths() + if err != nil { + return []string{}, err + } + + for _, searchPath := range searchPaths { + allCmd := exec.Command("fd", "-H", "-t", "d", "^\\.git$", searchPath) + allOutput, err := allCmd.Output() + if err != nil { + return paths, err + } + + allFiles := strings.Split(strings.TrimSpace(string(allOutput)), "\n") + if len(allFiles) == 1 && allFiles[0] == "" { + allFiles = []string{} + } + + for i, file := range allFiles { + allFiles[i] = path.Dir(path.Clean(file)) + } + + paths = append(paths, allFiles...) + } + + return paths, nil +} diff --git a/app/db.go b/app/db.go index 19deeb4..0a16271 100644 --- a/app/db.go +++ b/app/db.go @@ -1,5 +1,6 @@ package app +// TODO: app/db.go should be reviewed. import ( "database/sql" "encoding/json" @@ -149,9 +150,9 @@ func (db *Db) List() (results []EnvFile, err error) { } defer rows.Close() - var envFile EnvFile - var remotesJson []byte for rows.Next() { + var envFile EnvFile + var remotesJson []byte err := rows.Scan(&envFile.Path, &remotesJson, &envFile.Sha256, &envFile.contents) if err != nil { return nil, err @@ -386,3 +387,35 @@ func (db *Db) CanScan() error { return nil } } + +// If true, [Db.Insert] should be called on the [EnvFile] that generated +// the given result +func (db Db) UpdateRequired(status EnvFileSyncResult) bool { + return status&(BackedUp|DirUpdated) != 0 +} + +func (db *Db) Sync(file *EnvFile) (result EnvFileSyncResult, err error) { + // TODO: This results in findMovedDirs being called multiple times. + return file.sync(TrustFilesystem, db) +} + +// Looks for git directories that share one or more git remotes with +// the given file. +func (db Db) findMovedDirs(file *EnvFile) (movedDirs []string, err error) { + if err = db.Features().validateFeatures(Fd, Git); err != nil { + return movedDirs, err + } + + gitRoots, err := db.cfg.findGitRoots() + if err != nil { + return movedDirs, err + } else { + for _, dir := range gitRoots { + if file.sharesRemote(getGitRemotes(dir)) { + movedDirs = append(movedDirs, dir) + } + } + + return movedDirs, nil + } +} diff --git a/app/env_file.go b/app/env_file.go index bf38c5e..99d27fb 100644 --- a/app/env_file.go +++ b/app/env_file.go @@ -6,11 +6,13 @@ import ( "fmt" "os" "os/exec" + "path" "path/filepath" "strings" ) type EnvFile struct { + // TODO: Should use FileName in the struct and derive from the path. Path string // Dir is derived from Path, and is not stored in the database. Dir string @@ -23,18 +25,25 @@ type EnvFile struct { type EnvFileSyncResult int const ( - // The struct has been updated from the filesystem - // and should be updated in the database. - BackedUp EnvFileSyncResult = iota - // The filesystem has been restored to match the struct - // no further action is required. - Restored - Error // The filesystem contents matches the struct // no further action is required. - Noop + Noop EnvFileSyncResult = 0 + // The directory changed, but the file contents matched. + // The database must be updated. + DirUpdated EnvFileSyncResult = 1 + // The filesystem has been restored to match the struct + // no further action is required. + Restored EnvFileSyncResult = 1 << 1 + // The filesystem has been restored to match the struct. + // The directory changed, so the database must be updated + RestoredAndDirUpdated EnvFileSyncResult = Restored | DirUpdated + // The struct has been updated from the filesystem + // and should be updated in the database. + BackedUp EnvFileSyncResult = 1 << 2 + Error EnvFileSyncResult = 1 << 3 ) +// Determines the source of truth when calling [EnvFile.Sync] or [EnvFile.Restore] type syncDirection int const ( @@ -105,18 +114,33 @@ func getGitRemotes(dir string) []string { } // Reconcile the state of the database with the state of the filesystem, using -// dir to determine which side to use a the source of truth -func (f *EnvFile) sync(dir syncDirection) (result EnvFileSyncResult, err error) { - // How Sync should work - // - // If the directory doesn't exist, look for other directories with the same remote(s) - // -> If one is found, update file.Dir and File.Path, then continue with "changed" flag - // -> If multiple are found, return an error - // -> If none are found, return a different error +// dir to determine which side to use a the source of truth. +func (f *EnvFile) sync(dir syncDirection, db *Db) (result EnvFileSyncResult, err error) { + if result != Noop { + panic("Invalid state") + } - // Ensure the directory exists if _, err := os.Stat(f.Dir); err != nil { - return Error, fmt.Errorf("directory missing") + // Directory doesn't exist + + var movedDirs []string + + if db != nil { + movedDirs, err = db.findMovedDirs(f) + } + if err != nil { + return Error, err + } else { + switch len(movedDirs) { + case 0: + return Error, fmt.Errorf("directory missing") + case 1: + f.updateDir(movedDirs[0]) + result |= DirUpdated + default: + return Error, fmt.Errorf("multiple directories found") + } + } } if _, err := os.Stat(f.Path); err != nil { @@ -125,7 +149,7 @@ func (f *EnvFile) sync(dir syncDirection) (result EnvFileSyncResult, err error) return Error, fmt.Errorf("failed to write file: %w", err) } - return Restored, err + return result | Restored, nil } else { return Error, err } @@ -141,14 +165,16 @@ func (f *EnvFile) sync(dir syncDirection) (result EnvFileSyncResult, err error) // Compare the hashes if currentSha == f.Sha256 { - return Noop, nil + // No op, or DirUpdated + return result, nil } else { switch dir { case TrustDatabase: if err := os.WriteFile(f.Path, []byte(f.contents), 0644); err != nil { return Error, fmt.Errorf("failed to write file: %w", err) } - return Restored, nil + + return result | Restored, nil case TrustFilesystem: // Overwrite the database if err = f.Backup(); err != nil { @@ -163,17 +189,38 @@ func (f *EnvFile) sync(dir syncDirection) (result EnvFileSyncResult, err error) } } +func (f *EnvFile) sharesRemote(remotes []string) bool { + rMap := make(map[string]bool) + for _, remote := range f.Remotes { + rMap[remote] = true + } + + for _, remote := range remotes { + if rMap[remote] { + return true + } + } + + return false +} + +func (f *EnvFile) updateDir(newDir string) { + f.Dir = newDir + f.Path = path.Join(newDir, path.Base(f.Path)) + f.Remotes = getGitRemotes(newDir) +} + // Try to reconcile the EnvFile with the filesystem. // // If Updated is returned, [Db.Insert] should be called on file. func (file *EnvFile) Sync() (result EnvFileSyncResult, err error) { - return file.sync(TrustFilesystem) + return file.sync(TrustFilesystem, nil) } // Install the file into the file system. If the file already exists, // it will be overwritten. func (file EnvFile) Restore() error { - _, err := file.sync(TrustDatabase) + _, err := file.sync(TrustDatabase, nil) return err } diff --git a/app/features.go b/app/features.go index c510594..a57be10 100644 --- a/app/features.go +++ b/app/features.go @@ -1,9 +1,20 @@ package app import ( + "fmt" "os/exec" ) +type MissingFeatureError struct { + feature AvailableFeatures +} + +func (m *MissingFeatureError) Error() string { + return fmt.Sprintf("Missing \"%s\" feature", m.feature) +} + +// TODO: Features should really be renamed to Binaries + // Represents which binaries are present in $PATH. // Used to fail safely when required features are unavailable type AvailableFeatures int @@ -30,3 +41,20 @@ func checkFeatures() (feats AvailableFeatures) { return feats } + +// Returns a MissingFeature error if the given features aren't present. +func (a AvailableFeatures) validateFeatures(features ...AvailableFeatures) error { + var missing AvailableFeatures + + for _, feat := range features { + if a&feat == 0 { + missing |= feat + } + } + + if missing == 0 { + return nil + } else { + return &MissingFeatureError{missing} + } +} diff --git a/cmd/backup.go b/cmd/backup.go index 8dbe157..6be6454 100644 --- a/cmd/backup.go +++ b/cmd/backup.go @@ -31,7 +31,7 @@ var backupCmd = &cobra.Command{ record := app.NewEnvFile(path) if err := db.Insert(record); err != nil { - panic(err) + return err } else { fmt.Printf("Saved %s into the database", path) return nil diff --git a/cmd/sync.go b/cmd/sync.go index 78aac9d..70c3e71 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -15,6 +15,7 @@ var syncCmd = &cobra.Command{ Short: "Update or restore your env backups", RunE: func(cmd *cobra.Command, args []string) error { db, err := app.Open() + if err != nil { return err } else { @@ -32,7 +33,8 @@ var syncCmd = &cobra.Command{ for _, file := range files { // Syncronize the filesystem with the database. - changed, err := file.Sync() + oldPath := file.Path + changed, err := db.Sync(&file) var status string switch changed { @@ -42,6 +44,8 @@ var syncCmd = &cobra.Command{ return err } case app.Restored: + fallthrough + case app.RestoredAndDirUpdated: status = "Restored" case app.Error: if err == nil { @@ -50,10 +54,23 @@ var syncCmd = &cobra.Command{ status = err.Error() case app.Noop: status = "OK" + case app.DirUpdated: + status = "Moved" default: panic("Unknown result") } + if changed&app.DirUpdated == app.DirUpdated { + if err := db.Delete(oldPath); err != nil { + return err + } + } + if db.UpdateRequired(changed) { + if err := db.Insert(file); err != nil { + return err + } + } + results = append(results, syncResult{ Path: file.Path, Status: status, diff --git a/flake.nix b/flake.nix index 82f33bf..b42c941 100644 --- a/flake.nix +++ b/flake.nix @@ -61,7 +61,7 @@ packages.default = pkgs.buildGoModule rec { pname = "envr"; - version = "0.1.1"; + version = "0.2.0"; src = ./.; # If the build complains, uncomment this line # vendorHash = "sha256:0000000000000000000000000000000000000000000000000000";