Skip to content

Commit 4db0a4d

Browse files
Spencer Browersbrow
authored andcommitted
feat(sync): envr can now detect if directories have moved.
1 parent 638751f commit 4db0a4d

File tree

8 files changed

+187
-31
lines changed

8 files changed

+187
-31
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ be run on a cron.
1919
- 🔍 **Smart Scanning**: Automatically discover and import `.env` files in your
2020
home directory.
2121
-**Interactive CLI**: User-friendly prompts for file selection and management.
22+
- 🗂️ **Rename Detection**: Automatically finds and updates renamed/moved
23+
repositories.
2224

2325
## TODOS
24-
25-
- [ ] 🗂️ **Rename Detection**: Automatically handle renamed repositories.
26+
- [x] Rename Detection: automatically update moved files.
2627
- [ ] Allow use of keys from `ssh-agent`
2728
- [x] Allow configuration of ssh key.
2829
- [x] Allow multiple ssh keys.

app/config.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"os"
88
"os/exec"
9+
"path"
910
"path/filepath"
1011
"strings"
1112

@@ -235,3 +236,32 @@ func (s SshKeyPair) recipient() (age.Recipient, error) {
235236

236237
return id, nil
237238
}
239+
240+
// Use fd to find all git roots in the config's search paths
241+
func (c Config) findGitRoots() (paths []string, err error) {
242+
searchPaths, err := c.searchPaths()
243+
if err != nil {
244+
return []string{}, err
245+
}
246+
247+
for _, searchPath := range searchPaths {
248+
allCmd := exec.Command("fd", "-H", "-t", "d", "^\\.git$", searchPath)
249+
allOutput, err := allCmd.Output()
250+
if err != nil {
251+
return paths, err
252+
}
253+
254+
allFiles := strings.Split(strings.TrimSpace(string(allOutput)), "\n")
255+
if len(allFiles) == 1 && allFiles[0] == "" {
256+
allFiles = []string{}
257+
}
258+
259+
for i, file := range allFiles {
260+
allFiles[i] = path.Dir(path.Clean(file))
261+
}
262+
263+
paths = append(paths, allFiles...)
264+
}
265+
266+
return paths, nil
267+
}

app/db.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package app
22

3+
// TODO: app/db.go should be reviewed.
34
import (
45
"database/sql"
56
"encoding/json"
@@ -149,9 +150,9 @@ func (db *Db) List() (results []EnvFile, err error) {
149150
}
150151
defer rows.Close()
151152

152-
var envFile EnvFile
153-
var remotesJson []byte
154153
for rows.Next() {
154+
var envFile EnvFile
155+
var remotesJson []byte
155156
err := rows.Scan(&envFile.Path, &remotesJson, &envFile.Sha256, &envFile.contents)
156157
if err != nil {
157158
return nil, err
@@ -386,3 +387,35 @@ func (db *Db) CanScan() error {
386387
return nil
387388
}
388389
}
390+
391+
// If true, [Db.Insert] should be called on the [EnvFile] that generated
392+
// the given result
393+
func (db Db) UpdateRequired(status EnvFileSyncResult) bool {
394+
return status&(BackedUp|DirUpdated) != 0
395+
}
396+
397+
func (db *Db) Sync(file *EnvFile) (result EnvFileSyncResult, err error) {
398+
// TODO: This results in findMovedDirs being called multiple times.
399+
return file.sync(TrustFilesystem, db)
400+
}
401+
402+
// Looks for git directories that share one or more git remotes with
403+
// the given file.
404+
func (db Db) findMovedDirs(file *EnvFile) (movedDirs []string, err error) {
405+
if err = db.Features().validateFeatures(Fd, Git); err != nil {
406+
return movedDirs, err
407+
}
408+
409+
gitRoots, err := db.cfg.findGitRoots()
410+
if err != nil {
411+
return movedDirs, err
412+
} else {
413+
for _, dir := range gitRoots {
414+
if file.sharesRemote(getGitRemotes(dir)) {
415+
movedDirs = append(movedDirs, dir)
416+
}
417+
}
418+
419+
return movedDirs, nil
420+
}
421+
}

app/env_file.go

Lines changed: 71 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import (
66
"fmt"
77
"os"
88
"os/exec"
9+
"path"
910
"path/filepath"
1011
"strings"
1112
)
1213

1314
type EnvFile struct {
15+
// TODO: Should use FileName in the struct and derive from the path.
1416
Path string
1517
// Dir is derived from Path, and is not stored in the database.
1618
Dir string
@@ -23,18 +25,25 @@ type EnvFile struct {
2325
type EnvFileSyncResult int
2426

2527
const (
26-
// The struct has been updated from the filesystem
27-
// and should be updated in the database.
28-
BackedUp EnvFileSyncResult = iota
29-
// The filesystem has been restored to match the struct
30-
// no further action is required.
31-
Restored
32-
Error
3328
// The filesystem contents matches the struct
3429
// no further action is required.
35-
Noop
30+
Noop EnvFileSyncResult = 0
31+
// The directory changed, but the file contents matched.
32+
// The database must be updated.
33+
DirUpdated EnvFileSyncResult = 1
34+
// The filesystem has been restored to match the struct
35+
// no further action is required.
36+
Restored EnvFileSyncResult = 1 << 1
37+
// The filesystem has been restored to match the struct.
38+
// The directory changed, so the database must be updated
39+
RestoredAndDirUpdated EnvFileSyncResult = Restored | DirUpdated
40+
// The struct has been updated from the filesystem
41+
// and should be updated in the database.
42+
BackedUp EnvFileSyncResult = 1 << 2
43+
Error EnvFileSyncResult = 1 << 3
3644
)
3745

46+
// Determines the source of truth when calling [EnvFile.Sync] or [EnvFile.Restore]
3847
type syncDirection int
3948

4049
const (
@@ -105,18 +114,33 @@ func getGitRemotes(dir string) []string {
105114
}
106115

107116
// Reconcile the state of the database with the state of the filesystem, using
108-
// dir to determine which side to use a the source of truth
109-
func (f *EnvFile) sync(dir syncDirection) (result EnvFileSyncResult, err error) {
110-
// How Sync should work
111-
//
112-
// If the directory doesn't exist, look for other directories with the same remote(s)
113-
// -> If one is found, update file.Dir and File.Path, then continue with "changed" flag
114-
// -> If multiple are found, return an error
115-
// -> If none are found, return a different error
116-
117-
// Ensure the directory exists
117+
// dir to determine which side to use a the source of truth.
118+
func (f *EnvFile) sync(dir syncDirection, db *Db) (result EnvFileSyncResult, err error) {
119+
if result != Noop {
120+
panic("Invalid state")
121+
}
122+
118123
if _, err := os.Stat(f.Dir); err != nil {
119-
return Error, fmt.Errorf("directory missing")
124+
// Directory doesn't exist
125+
126+
var movedDirs []string
127+
128+
if db != nil {
129+
movedDirs, err = db.findMovedDirs(f)
130+
}
131+
if err != nil {
132+
return Error, err
133+
} else {
134+
switch len(movedDirs) {
135+
case 0:
136+
return Error, fmt.Errorf("directory missing")
137+
case 1:
138+
f.updateDir(movedDirs[0])
139+
result |= DirUpdated
140+
default:
141+
return Error, fmt.Errorf("multiple directories found")
142+
}
143+
}
120144
}
121145

122146
if _, err := os.Stat(f.Path); err != nil {
@@ -125,7 +149,7 @@ func (f *EnvFile) sync(dir syncDirection) (result EnvFileSyncResult, err error)
125149
return Error, fmt.Errorf("failed to write file: %w", err)
126150
}
127151

128-
return Restored, err
152+
return result | Restored, nil
129153
} else {
130154
return Error, err
131155
}
@@ -141,14 +165,16 @@ func (f *EnvFile) sync(dir syncDirection) (result EnvFileSyncResult, err error)
141165

142166
// Compare the hashes
143167
if currentSha == f.Sha256 {
144-
return Noop, nil
168+
// No op, or DirUpdated
169+
return result, nil
145170
} else {
146171
switch dir {
147172
case TrustDatabase:
148173
if err := os.WriteFile(f.Path, []byte(f.contents), 0644); err != nil {
149174
return Error, fmt.Errorf("failed to write file: %w", err)
150175
}
151-
return Restored, nil
176+
177+
return result | Restored, nil
152178
case TrustFilesystem:
153179
// Overwrite the database
154180
if err = f.Backup(); err != nil {
@@ -163,17 +189,38 @@ func (f *EnvFile) sync(dir syncDirection) (result EnvFileSyncResult, err error)
163189
}
164190
}
165191

192+
func (f *EnvFile) sharesRemote(remotes []string) bool {
193+
rMap := make(map[string]bool)
194+
for _, remote := range f.Remotes {
195+
rMap[remote] = true
196+
}
197+
198+
for _, remote := range remotes {
199+
if rMap[remote] {
200+
return true
201+
}
202+
}
203+
204+
return false
205+
}
206+
207+
func (f *EnvFile) updateDir(newDir string) {
208+
f.Dir = newDir
209+
f.Path = path.Join(newDir, path.Base(f.Path))
210+
f.Remotes = getGitRemotes(newDir)
211+
}
212+
166213
// Try to reconcile the EnvFile with the filesystem.
167214
//
168215
// If Updated is returned, [Db.Insert] should be called on file.
169216
func (file *EnvFile) Sync() (result EnvFileSyncResult, err error) {
170-
return file.sync(TrustFilesystem)
217+
return file.sync(TrustFilesystem, nil)
171218
}
172219

173220
// Install the file into the file system. If the file already exists,
174221
// it will be overwritten.
175222
func (file EnvFile) Restore() error {
176-
_, err := file.sync(TrustDatabase)
223+
_, err := file.sync(TrustDatabase, nil)
177224

178225
return err
179226
}

app/features.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
package app
22

33
import (
4+
"fmt"
45
"os/exec"
56
)
67

8+
type MissingFeatureError struct {
9+
feature AvailableFeatures
10+
}
11+
12+
func (m *MissingFeatureError) Error() string {
13+
return fmt.Sprintf("Missing \"%s\" feature", m.feature)
14+
}
15+
16+
// TODO: Features should really be renamed to Binaries
17+
718
// Represents which binaries are present in $PATH.
819
// Used to fail safely when required features are unavailable
920
type AvailableFeatures int
@@ -30,3 +41,20 @@ func checkFeatures() (feats AvailableFeatures) {
3041

3142
return feats
3243
}
44+
45+
// Returns a MissingFeature error if the given features aren't present.
46+
func (a AvailableFeatures) validateFeatures(features ...AvailableFeatures) error {
47+
var missing AvailableFeatures
48+
49+
for _, feat := range features {
50+
if a&feat == 0 {
51+
missing |= feat
52+
}
53+
}
54+
55+
if missing == 0 {
56+
return nil
57+
} else {
58+
return &MissingFeatureError{missing}
59+
}
60+
}

cmd/backup.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ var backupCmd = &cobra.Command{
3131
record := app.NewEnvFile(path)
3232

3333
if err := db.Insert(record); err != nil {
34-
panic(err)
34+
return err
3535
} else {
3636
fmt.Printf("Saved %s into the database", path)
3737
return nil

cmd/sync.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ var syncCmd = &cobra.Command{
1515
Short: "Update or restore your env backups",
1616
RunE: func(cmd *cobra.Command, args []string) error {
1717
db, err := app.Open()
18+
1819
if err != nil {
1920
return err
2021
} else {
@@ -32,7 +33,8 @@ var syncCmd = &cobra.Command{
3233

3334
for _, file := range files {
3435
// Syncronize the filesystem with the database.
35-
changed, err := file.Sync()
36+
oldPath := file.Path
37+
changed, err := db.Sync(&file)
3638

3739
var status string
3840
switch changed {
@@ -42,6 +44,8 @@ var syncCmd = &cobra.Command{
4244
return err
4345
}
4446
case app.Restored:
47+
fallthrough
48+
case app.RestoredAndDirUpdated:
4549
status = "Restored"
4650
case app.Error:
4751
if err == nil {
@@ -50,10 +54,23 @@ var syncCmd = &cobra.Command{
5054
status = err.Error()
5155
case app.Noop:
5256
status = "OK"
57+
case app.DirUpdated:
58+
status = "Moved"
5359
default:
5460
panic("Unknown result")
5561
}
5662

63+
if changed&app.DirUpdated == app.DirUpdated {
64+
if err := db.Delete(oldPath); err != nil {
65+
return err
66+
}
67+
}
68+
if db.UpdateRequired(changed) {
69+
if err := db.Insert(file); err != nil {
70+
return err
71+
}
72+
}
73+
5774
results = append(results, syncResult{
5875
Path: file.Path,
5976
Status: status,

flake.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161

6262
packages.default = pkgs.buildGoModule rec {
6363
pname = "envr";
64-
version = "0.1.1";
64+
version = "0.2.0";
6565
src = ./.;
6666
# If the build complains, uncomment this line
6767
# vendorHash = "sha256:0000000000000000000000000000000000000000000000000000";

0 commit comments

Comments
 (0)