From 15259204fe7c0a673531a3c053f85a9642e0509f Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Tue, 25 Aug 2026 14:30:22 -0500 Subject: [PATCH 1/4] feat: add project sync --all and local .lnkprojectcache for mapping --- .gitignore | 3 + cmd/root.go | 155 +++++++++++++--- service/doctor_project.go | 176 +++++++++--------- service/doctor_project_test.go | 60 ++++++- service/project.go | 147 +++++++++++++++ service/project_cache.go | 318 +++++++++++++++++++++++++++++++++ service/project_cache_test.go | 169 ++++++++++++++++++ service/project_test.go | 170 ++++++++++++++++++ 8 files changed, 1083 insertions(+), 115 deletions(-) create mode 100644 service/project_cache.go create mode 100644 service/project_cache_test.go diff --git a/.gitignore b/.gitignore index 66d2927..67f38f3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ coverage.html # Go workspace go.work go.work.sum + +# Machine-local project checkout cache +.lnkprojectcache diff --git a/cmd/root.go b/cmd/root.go index 018a57b..a28520b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -177,6 +177,7 @@ func newProjectCmd(repoFlag *string) *cobra.Command { cmd.AddCommand(newProjectUntrackCmd(repoFlag)) cmd.AddCommand(newProjectPushCmd(repoFlag)) cmd.AddCommand(newProjectSyncCmd(repoFlag)) + cmd.AddCommand(newProjectCacheCmd(repoFlag)) cmd.AddCommand(newProjectRestoreCmd(repoFlag)) cmd.AddCommand(newProjectPullCmd(repoFlag)) cmd.AddCommand(newProjectRemoveCmd(repoFlag)) @@ -428,65 +429,169 @@ func newProjectSyncCmd(repoFlag *string) *cobra.Command { var dryRun bool var pruneDeletions bool var force bool + var all bool cmd := &cobra.Command{ - Use: "sync [--dry-run] [--prune-deletions] [--force]", + Use: "sync [--all] [--dry-run] [--prune-deletions] [--force]", Short: "Reconcile patterns, live files, and project storage", RunE: func(cmd *cobra.Command, args []string) error { + ps := service.NewProjectService(svc(repoFlag)) + + if all { + result, err := ps.ProjectSyncAll(cmd.Context(), dryRun, pruneDeletions, force) + if err != nil { + return err + } + return printProjectSyncAll(cmd.OutOrStdout(), cmd.ErrOrStderr(), result, dryRun) + } + projectRoot, err := projectDir(cmd) if err != nil { return err } - ps := service.NewProjectService(svc(repoFlag)) result, err := ps.ProjectSync(cmd.Context(), projectRoot, dryRun, pruneDeletions, force) if err != nil { return err } - w := cmd.OutOrStdout() - if err := printSyncSection(w, dryRunPrefix(dryRun, "Synced", "Would sync"), result.Synced, "file(s) to project storage"); err != nil { - return err - } - if err := printSyncSection(w, dryRunPrefix(dryRun, "Restored", "Would restore"), result.Released, "file(s) to the project"); err != nil { + return printProjectSync(cmd.OutOrStdout(), cmd.ErrOrStderr(), result, dryRun) + }, + } + + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview reconciliation without changing files") + cmd.Flags().BoolVar(&pruneDeletions, "prune-deletions", false, "delete stored files whose live copies were deleted") + cmd.Flags().BoolVar(&force, "force", false, "also manage files tracked by the project's own git") + cmd.Flags().BoolVar(&all, "all", false, "reconcile every stored project using .lnkprojectcache") + return cmd +} + +// newProjectCacheCmd returns the "project cache" subcommand. +func newProjectCacheCmd(repoFlag *string) *cobra.Command { + var scanRoots []string + + cmd := &cobra.Command{ + Use: "cache --scan path [--scan path]...", + Short: "Discover local project checkouts and update .lnkprojectcache", + RunE: func(cmd *cobra.Command, args []string) error { + ps := service.NewProjectService(svc(repoFlag)) + result, err := ps.ProjectCacheDiscover(cmd.Context(), scanRoots) + if err != nil { return err } - if err := printSyncSection(w, dryRunPrefix(dryRun, "Backed up", "Would back up"), result.BackedUp, "conflicting file(s)"); err != nil { - return err + + w := cmd.OutOrStdout() + if len(result.Discovered) > 0 { + if _, err := fmt.Fprintf(w, "Discovered %d project(s):\n", len(result.Discovered)); err != nil { + return err + } + for _, id := range result.Discovered { + if _, err := fmt.Fprintf(w, " %s\n", id); err != nil { + return err + } + } } - if err := printSyncSection(w, dryRunPrefix(dryRun, "Pruned", "Would prune"), result.Pruned, "stored file(s) deleted from the project"); err != nil { - return err + if len(result.Validated) > 0 { + if _, err := fmt.Fprintf(w, "Validated %d project(s):\n", len(result.Validated)); err != nil { + return err + } + for _, id := range result.Validated { + if _, err := fmt.Fprintf(w, " %s\n", id); err != nil { + return err + } + } } - if len(result.Deletions) > 0 { - if _, err := fmt.Fprintf(w, "%d stored file(s) no longer exist in the project (run with --prune-deletions to drop them):\n", len(result.Deletions)); err != nil { + if len(result.Missing) > 0 { + if _, err := fmt.Fprintf(w, "Marked %d project(s) as missing:\n", len(result.Missing)); err != nil { return err } - for _, path := range result.Deletions { - if _, err := fmt.Fprintf(w, " %s\n", path); err != nil { + for _, id := range result.Missing { + if _, err := fmt.Fprintf(w, " %s\n", id); err != nil { return err } } } - for _, path := range result.SkippedTracked { - if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "warning: skipped '%s': tracked by this repo's git (add '!%s' to .lnkinclude, or use --force)\n", path, path); err != nil { + if len(result.Removed) > 0 { + if _, err := fmt.Fprintf(w, "Removed %d stale cache entry(ies):\n", len(result.Removed)); err != nil { return err } + for _, id := range result.Removed { + if _, err := fmt.Fprintf(w, " %s\n", id); err != nil { + return err + } + } } - - if len(result.Synced)+len(result.Released)+len(result.Pruned)+len(result.Deletions) == 0 { - _, err = fmt.Fprintln(w, "Project storage is in sync with the effective patterns") - return err + if len(result.Discovered)+len(result.Validated)+len(result.Missing)+len(result.Removed) == 0 { + _, err = fmt.Fprintln(w, "No changes to project cache") } - return nil + return err }, } - cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview reconciliation without changing files") - cmd.Flags().BoolVar(&pruneDeletions, "prune-deletions", false, "delete stored files whose live copies were deleted") - cmd.Flags().BoolVar(&force, "force", false, "also manage files tracked by the project's own git") + cmd.Flags().StringArrayVar(&scanRoots, "scan", nil, "directory to scan for local project checkouts (repeatable)") + _ = cmd.MarkFlagRequired("scan") return cmd } +// printProjectSync writes the output for a single project sync result. +func printProjectSync(w, errW io.Writer, result service.ProjectSyncResult, dryRun bool) error { + if err := printSyncSection(w, dryRunPrefix(dryRun, "Synced", "Would sync"), result.Synced, "file(s) to project storage"); err != nil { + return err + } + if err := printSyncSection(w, dryRunPrefix(dryRun, "Restored", "Would restore"), result.Released, "file(s) to the project"); err != nil { + return err + } + if err := printSyncSection(w, dryRunPrefix(dryRun, "Backed up", "Would back up"), result.BackedUp, "conflicting file(s)"); err != nil { + return err + } + if err := printSyncSection(w, dryRunPrefix(dryRun, "Pruned", "Would prune"), result.Pruned, "stored file(s) deleted from the project"); err != nil { + return err + } + if len(result.Deletions) > 0 { + if _, err := fmt.Fprintf(w, "%d stored file(s) no longer exist in the project (run with --prune-deletions to drop them):\n", len(result.Deletions)); err != nil { + return err + } + for _, path := range result.Deletions { + if _, err := fmt.Fprintf(w, " %s\n", path); err != nil { + return err + } + } + } + for _, path := range result.SkippedTracked { + if _, err := fmt.Fprintf(errW, "warning: skipped '%s': tracked by this repo's git (add '!%s' to .lnkinclude, or use --force)\n", path, path); err != nil { + return err + } + } + + if len(result.Synced)+len(result.Released)+len(result.Pruned)+len(result.Deletions) == 0 { + _, err := fmt.Fprintln(w, "Project storage is in sync with the effective patterns") + return err + } + return nil +} + +// printProjectSyncAll writes the output for `lnk project sync --all`. +func printProjectSyncAll(w, errW io.Writer, result service.ProjectSyncAllResult, dryRun bool) error { + for _, res := range result.Results { + if _, err := fmt.Fprintf(w, "# %s\n", res.ProjectID); err != nil { + return err + } + if err := printProjectSync(w, errW, res, dryRun); err != nil { + return err + } + } + for _, id := range result.Unavailable { + if _, err := fmt.Fprintf(errW, "warning: skipping %s: local checkout not found in scan roots\n", id); err != nil { + return err + } + } + if len(result.Results) == 0 && len(result.Unavailable) == 0 { + _, err := fmt.Fprintln(w, "No stored projects") + return err + } + return nil +} + // printSyncSection writes one titled list section of sync output. func printSyncSection(w io.Writer, title string, paths []string, suffix string) error { if len(paths) == 0 { diff --git a/service/doctor_project.go b/service/doctor_project.go index 997398e..bc39b8e 100644 --- a/service/doctor_project.go +++ b/service/doctor_project.go @@ -10,18 +10,34 @@ import ( "strings" "github.com/polymorcodeus/lnk/internal/patterns" - "github.com/polymorcodeus/lnk/internal/scope" ) // scanProjectIssues runs project-scope health checks and returns any findings. func (s *Service) scanProjectIssues(ctx context.Context) ([]ProjectIssue, error) { var issues []ProjectIssue + // Cache issues are reported first; orphaned storage skips IDs already + // covered by a cache issue to avoid duplicate warnings. + cacheIssues, err := s.findCacheIssues(ctx) + if err != nil { + return nil, fmt.Errorf("check project cache: %w", err) + } + issues = append(issues, cacheIssues...) + cacheIDs := make(map[string]struct{}) + for _, issue := range cacheIssues { + cacheIDs[issue.ProjectID] = struct{}{} + } + orphaned, err := s.findOrphanedProjectStorage(ctx) if err != nil { return nil, fmt.Errorf("find orphaned project storage: %w", err) } - issues = append(issues, orphaned...) + for _, issue := range orphaned { + if _, ok := cacheIDs[issue.ProjectID]; ok { + continue + } + issues = append(issues, issue) + } broken, err := s.findBrokenProjectSymlinks(ctx) if err != nil { @@ -38,8 +54,43 @@ func (s *Service) scanProjectIssues(ctx context.Context) ([]ProjectIssue, error) return issues, nil } -// findOrphanedProjectStorage returns issues for stored projects whose ID does -// not match any live project directory (identified by a .lnkinclude file). +// findCacheIssues validates the machine-local .lnkprojectcache and returns +// warnings for entries that point to missing or mismatched checkouts, as well +// as stored projects with no cache entry at all. +func (s *Service) findCacheIssues(ctx context.Context) ([]ProjectIssue, error) { + ps := NewProjectService(s) + check, err := ps.CheckProjectCache(ctx) + if err != nil { + return nil, err + } + + var issues []ProjectIssue + for _, entry := range check.Missing { + issues = append(issues, ProjectIssue{ + ProjectID: entry.ID, + Issue: "cached checkout path is missing or no longer matches the project origin", + Severity: "warning", + Suggestion: "run 'lnk project cache --scan ' to rediscover the checkout", + }) + } + for _, id := range check.Uncached { + issues = append(issues, ProjectIssue{ + ProjectID: id, + Issue: "no local checkout recorded in .lnkprojectcache", + Severity: "warning", + Suggestion: "run 'lnk project cache --scan ' to discover this project", + }) + } + + slices.SortFunc(issues, func(a, b ProjectIssue) int { + return strings.Compare(a.ProjectID, b.ProjectID) + }) + return issues, nil +} + +// findOrphanedProjectStorage returns issues for stored projects that have no +// available local checkout recorded in .lnkprojectcache. Intentionally +// not-downloaded projects are not reported as orphaned. func (s *Service) findOrphanedProjectStorage(ctx context.Context) ([]ProjectIssue, error) { stored, err := s.storedProjectIDs() if err != nil { @@ -49,46 +100,35 @@ func (s *Service) findOrphanedProjectStorage(ctx context.Context) ([]ProjectIssu return nil, nil } - home, err := s.homeDir() + ps := NewProjectService(s) + check, err := ps.CheckProjectCache(ctx) if err != nil { return nil, err } - liveIDs := make(map[string]struct{}) - err = filepath.Walk(home, func(path string, info os.FileInfo, err error) error { - if err != nil || !info.IsDir() || info.Name() == ".git" { - return nil - } - - manifest := filepath.Join(path, ".lnkinclude") - if _, statErr := os.Stat(manifest); errors.Is(statErr, os.ErrNotExist) { - return nil - } else if statErr != nil { - return statErr - } - - id, err := resolveProjectID(ctx, path) - if err != nil { - return nil - } - liveIDs[id] = struct{}{} - return nil - }) - if err != nil { - return nil, err + available := make(map[string]struct{}) + for _, entry := range check.Available { + available[entry.ID] = struct{}{} + } + notDownloaded := make(map[string]struct{}) + for _, entry := range check.NotDownloaded { + notDownloaded[entry.ID] = struct{}{} } var issues []ProjectIssue for _, id := range stored { - if _, ok := liveIDs[id]; ok { + if _, ok := available[id]; ok { + continue + } + if _, ok := notDownloaded[id]; ok { continue } issues = append(issues, ProjectIssue{ ProjectID: id, - Issue: "orphaned project storage with no corresponding repo on disk", + Issue: "orphaned project storage with no available local checkout", Severity: "warning", Suggestion: fmt.Sprintf( - "run 'rm -rf %s' or verify the project is still needed", + "run 'lnk project cache --scan ' to rediscover, or 'rm -rf %s' if the project is no longer needed", filepath.Join(s.repoPath, "projects", id)), }) } @@ -132,31 +172,20 @@ func (s *Service) storedProjectIDs() ([]string, error) { return result, nil } -// findBrokenProjectSymlinks walks live project directories and reports -// project-scope symlinks whose storage target no longer exists. +// findBrokenProjectSymlinks walks the available project checkouts recorded in +// .lnkprojectcache and reports project-scope symlinks whose storage target no +// longer exists. func (s *Service) findBrokenProjectSymlinks(ctx context.Context) ([]ProjectIssue, error) { - home, err := s.homeDir() + ps := NewProjectService(s) + check, err := ps.CheckProjectCache(ctx) if err != nil { return nil, err } var issues []ProjectIssue - err = filepath.Walk(home, func(path string, info os.FileInfo, err error) error { - if err != nil || !info.IsDir() || info.Name() == ".git" { - return nil - } - - manifest := filepath.Join(path, ".lnkinclude") - if _, statErr := os.Stat(manifest); errors.Is(statErr, os.ErrNotExist) { - return nil - } else if statErr != nil { - return statErr - } - - id, err := resolveProjectID(ctx, path) - if err != nil { - return nil - } + for _, entry := range check.Available { + id := entry.ID + path := entry.Path storageDir := filepath.Join(s.repoPath, "projects", id) _ = filepath.Walk(path, func(livePath string, liveInfo os.FileInfo, err error) error { @@ -194,11 +223,6 @@ func (s *Service) findBrokenProjectSymlinks(ctx context.Context) ([]ProjectIssue }) return nil }) - - return nil - }) - if err != nil { - return nil, err } slices.SortFunc(issues, func(a, b ProjectIssue) int { @@ -211,35 +235,25 @@ func (s *Service) findBrokenProjectSymlinks(ctx context.Context) ([]ProjectIssue } // findEmptyProjectPatterns reports .lnkinclude patterns that match no files in -// the project directory. +// the available project checkouts recorded in .lnkprojectcache. func (s *Service) findEmptyProjectPatterns(ctx context.Context) ([]ProjectIssue, error) { - home, err := s.homeDir() + ps := NewProjectService(s) + check, err := ps.CheckProjectCache(ctx) if err != nil { return nil, err } - var issues []ProjectIssue - err = filepath.Walk(home, func(path string, info os.FileInfo, err error) error { - if err != nil || !info.IsDir() || info.Name() == ".git" { - return nil - } + global, _ := patterns.Load(filepath.Join(s.repoPath, ".lnkinclude")) + var issues []ProjectIssue + for _, entry := range check.Available { + id := entry.ID + path := entry.Path manifest := filepath.Join(path, ".lnkinclude") - if _, statErr := os.Stat(manifest); errors.Is(statErr, os.ErrNotExist) { - return nil - } else if statErr != nil { - return statErr - } - - id, err := resolveProjectID(ctx, path) - if err != nil { - return nil - } - global, _ := patterns.Load(filepath.Join(s.repoPath, ".lnkinclude")) local, err := patterns.Load(manifest) if err != nil { - return nil + continue } allPatterns := append(global, local...) @@ -251,7 +265,7 @@ func (s *Service) findEmptyProjectPatterns(ctx context.Context) ([]ProjectIssue, patternMatches[p] = 0 } if len(patternMatches) == 0 { - return nil + continue } _ = filepath.Walk(path, func(filePath string, fileInfo os.FileInfo, err error) error { @@ -293,11 +307,6 @@ func (s *Service) findEmptyProjectPatterns(ctx context.Context) ([]ProjectIssue, manifest), }) } - - return nil - }) - if err != nil { - return nil, err } slices.SortFunc(issues, func(a, b ProjectIssue) int { @@ -308,12 +317,3 @@ func (s *Service) findEmptyProjectPatterns(ctx context.Context) ([]ProjectIssue, }) return issues, nil } - -// homeDir returns the user's home directory. It prefers the home directory -// embedded in the service's resolver when available. -func (s *Service) homeDir() (string, error) { - if r, ok := s.resolver.(*scope.HomeRelativeResolver); ok { - return r.Home, nil - } - return os.UserHomeDir() -} diff --git a/service/doctor_project_test.go b/service/doctor_project_test.go index 8db8a2b..85b3103 100644 --- a/service/doctor_project_test.go +++ b/service/doctor_project_test.go @@ -36,8 +36,8 @@ func TestDoctor_ProjectIssues_OrphanedStorage(t *testing.T) { if !report.HasIssues() { t.Fatal("expected doctor to report issues") } - if !hasProjectIssue(report.ProjectIssues, "github.com/alice/orphaned", "orphaned") { - t.Errorf("ProjectIssues = %v, expected orphaned storage issue", report.ProjectIssues) + if !hasProjectIssue(report.ProjectIssues, "github.com/alice/orphaned", ".lnkprojectcache") { + t.Errorf("ProjectIssues = %v, expected cache issue for orphaned storage", report.ProjectIssues) } _ = home } @@ -97,6 +97,11 @@ func TestDoctor_ProjectIssues_EmptyPattern(t *testing.T) { t.Fatal(err) } + ps := service.NewProjectService(svc) + if _, err := ps.ProjectPush(context.Background(), projectDir, false); err != nil { + t.Fatalf("ProjectPush: %v", err) + } + report, err := svc.Doctor(context.Background(), "", false, false, false) if err != nil { t.Fatalf("Doctor: %v", err) @@ -129,6 +134,9 @@ func TestDoctor_ProjectIssues_NoIssues(t *testing.T) { if _, err := ps.ProjectPush(context.Background(), projectDir, false); err != nil { t.Fatalf("ProjectPush: %v", err) } + if _, err := ps.ProjectCacheDiscover(context.Background(), []string{filepath.Dir(projectDir)}); err != nil { + t.Fatalf("ProjectCacheDiscover: %v", err) + } report, err := svc.Doctor(context.Background(), "", false, false, false) if err != nil { @@ -147,3 +155,51 @@ func hasProjectIssue(issues []service.ProjectIssue, projectID, issueSubstr strin return i.ProjectID == projectID && strings.Contains(i.Issue, issueSubstr) }) } + +func TestDoctor_ProjectIssues_CacheMissing(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "projects", "myapp") + if err := os.MkdirAll(repoDir, 0o755); err != nil { + t.Fatal(err) + } + testhelpers.InitGitRepo(t, repoDir) + if out, err := execGit(t, repoDir, "remote", "add", "origin", "git@github.com:alice/myapp.git"); err != nil { + t.Fatalf("git remote add: %v\n%s", err, out) + } + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, "config"); err != nil { + t.Fatalf("add pattern: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, "config"), []byte("config"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ps.ProjectPush(context.Background(), repoDir, false); err != nil { + t.Fatalf("push: %v", err) + } + // ProjectPush records the cache; clear it to verify the doctor warning. + if err := os.Remove(filepath.Join(svc.RepoPath(), ".lnkprojectcache")); err != nil && !os.IsNotExist(err) { + t.Fatalf("clear cache: %v", err) + } + + // No cache entry exists yet, so doctor reports the project as uncached. + report, err := svc.Doctor(context.Background(), "", false, false, false) + if err != nil { + t.Fatalf("Doctor: %v", err) + } + if !hasProjectIssue(report.ProjectIssues, "github.com/alice/myapp", ".lnkprojectcache") { + t.Errorf("expected cache issue, got %v", report.ProjectIssues) + } + + // Repair via project cache --scan. + if _, err := ps.ProjectCacheDiscover(context.Background(), []string{filepath.Dir(repoDir)}); err != nil { + t.Fatalf("ProjectCacheDiscover: %v", err) + } + report, err = svc.Doctor(context.Background(), "", false, false, false) + if err != nil { + t.Fatalf("Doctor after cache repair: %v", err) + } + if hasProjectIssue(report.ProjectIssues, "github.com/alice/myapp", ".lnkprojectcache") { + t.Errorf("expected cache issue to be repaired, got %v", report.ProjectIssues) + } +} diff --git a/service/project.go b/service/project.go index 0ae385f..d0fce6a 100644 --- a/service/project.go +++ b/service/project.go @@ -535,6 +535,10 @@ func (ps *ProjectService) ProjectPush(ctx context.Context, projectRoot string, f return result, fmt.Errorf("%w: %w", lnkerror.ErrSyncFailed, errors.Join(stats.failed...)) } + if err := ps.recordProjectCache(ctx, root, id); err != nil { + return result, err + } + return result, nil } @@ -880,9 +884,152 @@ func (ps *ProjectService) ProjectSync(ctx context.Context, projectRoot string, d return result, fmt.Errorf("%w: %w", lnkerror.ErrSyncFailed, errors.Join(stats.failed...)) } + if err := ps.recordProjectCache(ctx, root, id); err != nil { + return result, err + } + + return result, nil +} + +// ProjectSyncAllResult reports the outcome of syncing every stored project. +type ProjectSyncAllResult struct { + // Results contains the per-project reconciliation outcomes for projects + // listed as available in the local cache. + Results []ProjectSyncResult + // Unavailable lists stored project IDs whose cache entry is missing or not + // available on this machine. + Unavailable []string +} + +// ProjectSyncAll reconciles every stored project that is marked available in +// the machine-local .lnkprojectcache. Projects marked not-downloaded or +// missing, or projects with no cache entry, are reported in Unavailable. +// Errors from individual projects are joined and returned alongside the +// partial result. +func (ps *ProjectService) ProjectSyncAll(ctx context.Context, dryRun, pruneDeletions, force bool) (ProjectSyncAllResult, error) { + stored, err := ps.ProjectListProjects() + if err != nil { + return ProjectSyncAllResult{}, err + } + if len(stored) == 0 { + return ProjectSyncAllResult{}, nil + } + + cache, err := ps.LoadProjectCache() + if err != nil { + return ProjectSyncAllResult{}, err + } + + result := ProjectSyncAllResult{} + var errs []error + for _, p := range stored { + entry, ok := cache.Get(p.ID) + if !ok || entry.State != CacheStateAvailable { + result.Unavailable = append(result.Unavailable, p.ID) + continue + } + + res, err := ps.ProjectSync(ctx, entry.Path, dryRun, pruneDeletions, force) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", p.ID, err)) + } + result.Results = append(result.Results, res) + } + + return result, errors.Join(errs...) +} + +// discoverProjectRoots scans scanRoots for git working trees and returns a map +// from project ID to the discovered local checkout paths. Hidden directories +// (except .git itself) are skipped, nested git directories are skipped once a +// repo root is found, and the lnk repository itself is excluded. +func (ps *ProjectService) discoverProjectRoots(ctx context.Context, scanRoots []string) (map[string][]string, error) { + result := make(map[string][]string) + seen := make(map[string]struct{}) + + for _, root := range scanRoots { + info, err := os.Stat(root) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return nil, fmt.Errorf("scan root %s: %w", root, err) + } + if !info.IsDir() { + continue + } + + absRoot, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("resolve scan root %s: %w", root, err) + } + + if err := filepath.Walk(absRoot, func(path string, fi os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if fi.IsDir() && path != absRoot { + name := fi.Name() + // Skip hidden directories except the .git directories we are + // explicitly looking for. + if strings.HasPrefix(name, ".") && name != ".git" { + return filepath.SkipDir + } + // Avoid descending into the lnk repo or its storage. + if ps.svc.isLnkRepoRoot(path) { + return filepath.SkipDir + } + // Bound the scan depth to avoid walking huge trees. + rel, err := filepath.Rel(absRoot, path) + if err != nil { + return err + } + if depth(rel) > 3 { + return filepath.SkipDir + } + } + + if !fi.IsDir() || fi.Name() != ".git" { + return nil + } + + repoRoot := filepath.Dir(path) + if _, ok := seen[repoRoot]; ok { + return filepath.SkipDir + } + seen[repoRoot] = struct{}{} + + if ps.svc.isLnkRepoRoot(repoRoot) { + return filepath.SkipDir + } + + id, err := ps.projectID(ctx, repoRoot) + if err != nil { + // Repos without a resolvable ID are ignored. + if errors.Is(err, resolver.ErrNoOrigin) { + return filepath.SkipDir + } + return err + } + result[id] = append(result[id], repoRoot) + return filepath.SkipDir + }); err != nil { + return nil, err + } + } + return result, nil } +// depth counts the number of path components in rel, which is assumed to use +// the local separator. +func depth(rel string) int { + if rel == "." { + return 0 + } + return len(strings.Split(rel, string(filepath.Separator))) +} + // ProjectRemoveResult reports the outcome of ProjectRemove. type ProjectRemoveResult struct { ProjectID string diff --git a/service/project_cache.go b/service/project_cache.go new file mode 100644 index 0000000..7f7c902 --- /dev/null +++ b/service/project_cache.go @@ -0,0 +1,318 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/polymorcodeus/lnk/internal/lnkerror" + "github.com/polymorcodeus/lnk/internal/resolver" +) + +// projectCacheFile is the machine-local mapping from stored project IDs to +// their local checkout paths. It is gitignored so absolute paths are not +// synced across machines. +const projectCacheFile = ".lnkprojectcache" + +// ProjectCacheState describes the local availability of a cached project. +type ProjectCacheState string + +const ( + // CacheStateAvailable means the project has a valid local checkout. + CacheStateAvailable ProjectCacheState = "available" + // CacheStateNotDownloaded means the project is stored but intentionally + // not present on this machine. + CacheStateNotDownloaded ProjectCacheState = "not-downloaded" + // CacheStateMissing means the cached path no longer points to a valid + // checkout with a matching origin. + CacheStateMissing ProjectCacheState = "missing" +) + +// ProjectCacheEntry maps one stored project ID to a local checkout path and +// its current availability state. +type ProjectCacheEntry struct { + ID string `json:"id"` + Path string `json:"path"` + State ProjectCacheState `json:"state"` +} + +// ProjectCache is the on-disk cache format. +type ProjectCache struct { + Projects []ProjectCacheEntry `json:"projects"` +} + +// projectCachePath returns the absolute path to the cache file. +func (ps *ProjectService) projectCachePath() string { + return filepath.Join(ps.svc.RepoPath(), projectCacheFile) +} + +// LoadProjectCache reads the local project cache. A missing cache is treated +// as an empty cache rather than an error. +func (ps *ProjectService) LoadProjectCache() (*ProjectCache, error) { + path := ps.projectCachePath() + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &ProjectCache{}, nil + } + return nil, fmt.Errorf("read project cache: %w", err) + } + + var cache ProjectCache + if err := json.Unmarshal(data, &cache); err != nil { + return nil, fmt.Errorf("parse project cache: %w", err) + } + return &cache, nil +} + +// SaveProjectCache writes the cache to disk in the lnk repo. +func (ps *ProjectService) SaveProjectCache(cache *ProjectCache) error { + data, err := json.MarshalIndent(cache, "", " ") + if err != nil { + return fmt.Errorf("encode project cache: %w", err) + } + data = append(data, '\n') + if err := os.WriteFile(ps.projectCachePath(), data, 0o644); err != nil { + return fmt.Errorf("write project cache: %w", err) + } + return nil +} + +// Get returns the cache entry for id, if present. +func (c *ProjectCache) Get(id string) (ProjectCacheEntry, bool) { + for _, e := range c.Projects { + if e.ID == id { + return e, true + } + } + return ProjectCacheEntry{}, false +} + +// Set adds or updates the entry for id. +func (c *ProjectCache) Set(entry ProjectCacheEntry) { + for i, e := range c.Projects { + if e.ID == entry.ID { + c.Projects[i] = entry + return + } + } + c.Projects = append(c.Projects, entry) +} + +// Remove drops the entry for id. +func (c *ProjectCache) Remove(id string) { + c.Projects = slices.DeleteFunc(c.Projects, func(e ProjectCacheEntry) bool { + return e.ID == id + }) +} + +// ProjectCacheDiscoverResult reports what a cache discovery pass changed. +type ProjectCacheDiscoverResult struct { + Discovered []string + Validated []string + Missing []string + Removed []string +} + +// ProjectCacheDiscover scans scanRoots for git checkouts, validates any +// existing cache entries, and updates the cache on disk. scanRoots must be +// non-empty; callers must explicitly choose which directories to scan. +// Existing entries whose cached path is invalid are marked missing; newly +// discovered projects are added as available. +func (ps *ProjectService) ProjectCacheDiscover(ctx context.Context, scanRoots []string) (ProjectCacheDiscoverResult, error) { + if len(scanRoots) == 0 { + return ProjectCacheDiscoverResult{}, lnkerror.WithSuggestion(lnkerror.ErrInvalidFlags, "pass at least one --scan directory") + } + + cache, err := ps.LoadProjectCache() + if err != nil { + return ProjectCacheDiscoverResult{}, err + } + + discovered, err := ps.discoverProjectRoots(ctx, scanRoots) + if err != nil { + return ProjectCacheDiscoverResult{}, err + } + + stored, err := ps.ProjectListProjects() + if err != nil { + return ProjectCacheDiscoverResult{}, err + } + storedIDs := make(map[string]struct{}) + for _, p := range stored { + storedIDs[p.ID] = struct{}{} + } + + result := ProjectCacheDiscoverResult{} + + // Validate existing entries and mark missing ones. + for _, entry := range cache.Projects { + if _, ok := storedIDs[entry.ID]; !ok { + cache.Remove(entry.ID) + result.Removed = append(result.Removed, entry.ID) + continue + } + + if entry.State == CacheStateNotDownloaded { + continue + } + + valid, err := ps.isValidProjectRoot(ctx, entry.Path, entry.ID) + if err != nil { + return result, err + } + if valid { + cache.Set(ProjectCacheEntry{ID: entry.ID, Path: entry.Path, State: CacheStateAvailable}) + result.Validated = append(result.Validated, entry.ID) + } else { + cache.Set(ProjectCacheEntry{ID: entry.ID, Path: entry.Path, State: CacheStateMissing}) + result.Missing = append(result.Missing, entry.ID) + } + } + + // Add newly discovered projects that are not already cached. + for _, p := range stored { + if _, ok := cache.Get(p.ID); ok { + continue + } + candidates, ok := discovered[p.ID] + if !ok || len(candidates) == 0 { + continue + } + cache.Set(ProjectCacheEntry{ID: p.ID, Path: candidates[0], State: CacheStateAvailable}) + result.Discovered = append(result.Discovered, p.ID) + } + + // Mark any remaining stored project without a cache entry as missing + // so sync --all reports it clearly. + for _, p := range stored { + if _, ok := cache.Get(p.ID); ok { + continue + } + cache.Set(ProjectCacheEntry{ID: p.ID, Path: "", State: CacheStateMissing}) + } + + slices.Sort(result.Discovered) + slices.Sort(result.Validated) + slices.Sort(result.Missing) + slices.Sort(result.Removed) + + if err := ps.SaveProjectCache(cache); err != nil { + return result, err + } + return result, nil +} + +// isValidProjectRoot reports whether root is a git working tree whose origin +// remote normalizes to the expected project ID. +func (ps *ProjectService) isValidProjectRoot(ctx context.Context, root, expectedID string) (bool, error) { + if root == "" { + return false, nil + } + info, err := os.Stat(root) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("check project root %s: %w", root, err) + } + if !info.IsDir() { + return false, nil + } + + id, err := ps.projectID(ctx, root) + if err != nil { + if errors.Is(err, resolver.ErrNoOrigin) { + return false, nil + } + return false, err + } + return id == expectedID, nil +} + +// ProjectCacheCheckResult reports the health of the local project cache. +type ProjectCacheCheckResult struct { + Available []ProjectCacheEntry + NotDownloaded []ProjectCacheEntry + Missing []ProjectCacheEntry + Uncached []string // stored project IDs with no cache entry +} + +// recordProjectCache marks the project rooted at root with the given ID as +// available in the local cache. It is called automatically after a successful +// ProjectPush or ProjectSync. +func (ps *ProjectService) recordProjectCache(ctx context.Context, root, id string) error { + valid, err := ps.isValidProjectRoot(ctx, root, id) + if err != nil { + return err + } + if !valid { + return nil + } + cache, err := ps.LoadProjectCache() + if err != nil { + return err + } + cache.Set(ProjectCacheEntry{ID: id, Path: root, State: CacheStateAvailable}) + return ps.SaveProjectCache(cache) +} + +// CheckProjectCache validates the existing cache against stored projects and +// on-disk state without modifying it. +func (ps *ProjectService) CheckProjectCache(ctx context.Context) (ProjectCacheCheckResult, error) { + cache, err := ps.LoadProjectCache() + if err != nil { + return ProjectCacheCheckResult{}, err + } + + stored, err := ps.ProjectListProjects() + if err != nil { + return ProjectCacheCheckResult{}, err + } + + result := ProjectCacheCheckResult{} + seen := make(map[string]struct{}) + for _, entry := range cache.Projects { + seen[entry.ID] = struct{}{} + switch entry.State { + case CacheStateAvailable: + valid, err := ps.isValidProjectRoot(ctx, entry.Path, entry.ID) + if err != nil { + return result, err + } + if valid { + result.Available = append(result.Available, entry) + } else { + result.Missing = append(result.Missing, entry) + } + case CacheStateNotDownloaded: + result.NotDownloaded = append(result.NotDownloaded, entry) + case CacheStateMissing: + result.Missing = append(result.Missing, entry) + } + } + + for _, p := range stored { + if _, ok := seen[p.ID]; !ok { + result.Uncached = append(result.Uncached, p.ID) + } + } + + slices.SortFunc(result.Available, func(a, b ProjectCacheEntry) int { + return strings.Compare(a.ID, b.ID) + }) + slices.SortFunc(result.NotDownloaded, func(a, b ProjectCacheEntry) int { + return strings.Compare(a.ID, b.ID) + }) + slices.SortFunc(result.Missing, func(a, b ProjectCacheEntry) int { + return strings.Compare(a.ID, b.ID) + }) + slices.Sort(result.Uncached) + + return result, nil +} diff --git a/service/project_cache_test.go b/service/project_cache_test.go new file mode 100644 index 0000000..8d03d38 --- /dev/null +++ b/service/project_cache_test.go @@ -0,0 +1,169 @@ +package service_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/polymorcodeus/lnk/internal/testhelpers" + "github.com/polymorcodeus/lnk/service" +) + +func TestProjectCache_LoadSaveAndGetSetRemove(t *testing.T) { + svc, _ := testhelpers.TestHome(t) + ps := service.NewProjectService(svc) + + cache, err := ps.LoadProjectCache() + if err != nil { + t.Fatalf("LoadProjectCache: %v", err) + } + if len(cache.Projects) != 0 { + t.Errorf("new cache has %d entries, want 0", len(cache.Projects)) + } + + cache.Set(service.ProjectCacheEntry{ID: "github.com/user/repo", Path: "/tmp/repo", State: service.CacheStateAvailable}) + if err := ps.SaveProjectCache(cache); err != nil { + t.Fatalf("SaveProjectCache: %v", err) + } + + loaded, err := ps.LoadProjectCache() + if err != nil { + t.Fatalf("LoadProjectCache after save: %v", err) + } + entry, ok := loaded.Get("github.com/user/repo") + if !ok { + t.Fatal("expected cached entry") + } + if entry.Path != "/tmp/repo" || entry.State != service.CacheStateAvailable { + t.Errorf("entry = %+v, want available /tmp/repo", entry) + } + + cache.Remove("github.com/user/repo") + if _, ok := cache.Get("github.com/user/repo"); ok { + t.Error("expected entry to be removed") + } +} + +func clearProjectCache(t *testing.T, svc *service.Service) { + t.Helper() + path := filepath.Join(svc.RepoPath(), ".lnkprojectcache") + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + t.Fatalf("clear project cache: %v", err) + } +} + +func TestProjectCacheDiscover_DiscoversAndValidates(t *testing.T) { + svc, home := testhelpers.TestHome(t) + parent := filepath.Join(home, "repos") + repoDir := filepath.Join(parent, "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".todo/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + testhelpers.MakeFile(t, filepath.Join(repoDir, ".todo", "a.md"), "a\n") + if _, err := ps.ProjectPush(context.Background(), repoDir, false); err != nil { + t.Fatalf("push: %v", err) + } + // ProjectPush auto-records the cache; clear it to test discovery. + clearProjectCache(t, svc) + + result, err := ps.ProjectCacheDiscover(context.Background(), []string{parent}) + if err != nil { + t.Fatalf("ProjectCacheDiscover: %v", err) + } + if len(result.Discovered) != 1 || result.Discovered[0] != "github.com/user/repo" { + t.Errorf("discovered = %v, want [github.com/user/repo]", result.Discovered) + } + if len(result.Validated) != 0 { + t.Errorf("validated = %v, want none", result.Validated) + } + + // Re-discovering the same root should validate instead of discover. + result, err = ps.ProjectCacheDiscover(context.Background(), []string{parent}) + if err != nil { + t.Fatalf("ProjectCacheDiscover second pass: %v", err) + } + if len(result.Discovered) != 0 { + t.Errorf("discovered = %v, want none on second pass", result.Discovered) + } + if len(result.Validated) != 1 || result.Validated[0] != "github.com/user/repo" { + t.Errorf("validated = %v, want [github.com/user/repo]", result.Validated) + } +} + +func TestProjectCacheDiscover_MarksMissingWhenCheckoutGone(t *testing.T) { + svc, home := testhelpers.TestHome(t) + parent := filepath.Join(home, "repos") + repoDir := filepath.Join(parent, "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".todo/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + testhelpers.MakeFile(t, filepath.Join(repoDir, ".todo", "a.md"), "a\n") + if _, err := ps.ProjectPush(context.Background(), repoDir, false); err != nil { + t.Fatalf("push: %v", err) + } + + if _, err := ps.ProjectCacheDiscover(context.Background(), []string{parent}); err != nil { + t.Fatalf("ProjectCacheDiscover: %v", err) + } + + if err := os.RemoveAll(repoDir); err != nil { + t.Fatal(err) + } + + result, err := ps.ProjectCacheDiscover(context.Background(), []string{parent}) + if err != nil { + t.Fatalf("ProjectCacheDiscover after removal: %v", err) + } + if len(result.Missing) != 1 || result.Missing[0] != "github.com/user/repo" { + t.Errorf("missing = %v, want [github.com/user/repo]", result.Missing) + } +} + +func TestCheckProjectCache(t *testing.T) { + svc, home := testhelpers.TestHome(t) + parent := filepath.Join(home, "repos") + repoDir := filepath.Join(parent, "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".todo/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + testhelpers.MakeFile(t, filepath.Join(repoDir, ".todo", "a.md"), "a\n") + if _, err := ps.ProjectPush(context.Background(), repoDir, false); err != nil { + t.Fatalf("push: %v", err) + } + // ProjectPush auto-records the cache; clear it to test the uncached state. + clearProjectCache(t, svc) + + // Without a cache entry the project is uncached. + check, err := ps.CheckProjectCache(context.Background()) + if err != nil { + t.Fatalf("CheckProjectCache: %v", err) + } + if len(check.Uncached) != 1 || check.Uncached[0] != "github.com/user/repo" { + t.Errorf("uncached = %v, want [github.com/user/repo]", check.Uncached) + } + + if _, err := ps.ProjectCacheDiscover(context.Background(), []string{parent}); err != nil { + t.Fatalf("ProjectCacheDiscover: %v", err) + } + + check, err = ps.CheckProjectCache(context.Background()) + if err != nil { + t.Fatalf("CheckProjectCache after discover: %v", err) + } + if len(check.Available) != 1 || check.Available[0].ID != "github.com/user/repo" { + t.Errorf("available = %v, want one github.com/user/repo entry", check.Available) + } +} diff --git a/service/project_test.go b/service/project_test.go index 796abd4..e99a342 100644 --- a/service/project_test.go +++ b/service/project_test.go @@ -1270,6 +1270,176 @@ func TestProjectSync_SkipsProjectGitTrackedUnlessForced(t *testing.T) { } } +func TestProjectSyncAll_DiscoversAndSyncsMultipleProjects(t *testing.T) { + svc, home := testhelpers.TestHome(t) + parent := filepath.Join(home, "repos") + + // First project: alpha. + alphaDir := filepath.Join(parent, "alpha") + testhelpers.MakeDir(t, alphaDir) + initProjectRepoWithRemote(t, alphaDir, "git@github.com:User/Alpha.git") + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), alphaDir, ".todo/**"); err != nil { + t.Fatalf("add pattern alpha: %v", err) + } + testhelpers.MakeFile(t, filepath.Join(alphaDir, ".todo", "a.md"), "alpha\n") + if _, err := ps.ProjectPush(context.Background(), alphaDir, false); err != nil { + t.Fatalf("push alpha: %v", err) + } + + // Second project: beta. + betaDir := filepath.Join(parent, "beta") + testhelpers.MakeDir(t, betaDir) + initProjectRepoWithRemote(t, betaDir, "git@github.com:User/Beta.git") + if _, _, err := ps.ProjectAddPattern(context.Background(), betaDir, "notes.md"); err != nil { + t.Fatalf("add pattern beta: %v", err) + } + testhelpers.MakeFile(t, filepath.Join(betaDir, "notes.md"), "beta\n") + if _, err := ps.ProjectPush(context.Background(), betaDir, false); err != nil { + t.Fatalf("push beta: %v", err) + } + + // Add new files and run sync --all over the parent directory. + testhelpers.MakeFile(t, filepath.Join(alphaDir, ".todo", "b.md"), "alpha b\n") + testhelpers.MakeFile(t, filepath.Join(betaDir, "notes2.md"), "beta 2\n") + if _, _, err := ps.ProjectAddPattern(context.Background(), betaDir, "notes2.md"); err != nil { + t.Fatalf("add pattern notes2: %v", err) + } + + // Populate the local cache by discovering checkouts under parent. + if _, err := ps.ProjectCacheDiscover(context.Background(), []string{parent}); err != nil { + t.Fatalf("ProjectCacheDiscover: %v", err) + } + + result, err := ps.ProjectSyncAll(context.Background(), false, false, false) + if err != nil { + t.Fatalf("ProjectSyncAll: %v", err) + } + if len(result.Unavailable) != 0 { + t.Errorf("unavailable = %v, want none", result.Unavailable) + } + if len(result.Results) != 2 { + t.Fatalf("results = %d, want 2", len(result.Results)) + } + + byID := map[string]int{} + for _, r := range result.Results { + byID[r.ProjectID] = len(r.Synced) + } + if byID["github.com/user/alpha"] != 1 { + t.Errorf("alpha synced = %d, want 1", byID["github.com/user/alpha"]) + } + if byID["github.com/user/beta"] != 1 { + t.Errorf("beta synced = %d, want 1", byID["github.com/user/beta"]) + } +} + +func TestProjectSyncAll_ReportsUnavailable(t *testing.T) { + svc, home := testhelpers.TestHome(t) + parent := filepath.Join(home, "repos") + + // Push a project but do not discover its checkout. + repoDir := filepath.Join(parent, "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".todo/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + testhelpers.MakeFile(t, filepath.Join(repoDir, ".todo", "a.md"), "a\n") + if _, err := ps.ProjectPush(context.Background(), repoDir, false); err != nil { + t.Fatalf("push: %v", err) + } + // ProjectPush records the cache; clear it and then discover an empty + // directory so the cache records the project as missing. + if err := os.Remove(filepath.Join(svc.RepoPath(), ".lnkprojectcache")); err != nil && !os.IsNotExist(err) { + t.Fatalf("clear cache: %v", err) + } + emptyDir := filepath.Join(home, "empty") + testhelpers.MakeDir(t, emptyDir) + if _, err := ps.ProjectCacheDiscover(context.Background(), []string{emptyDir}); err != nil { + t.Fatalf("ProjectCacheDiscover: %v", err) + } + + result, err := ps.ProjectSyncAll(context.Background(), false, false, false) + if err != nil { + t.Fatalf("ProjectSyncAll: %v", err) + } + if len(result.Results) != 0 { + t.Errorf("results = %v, want none", result.Results) + } + if len(result.Unavailable) != 1 || result.Unavailable[0] != "github.com/user/repo" { + t.Errorf("unavailable = %v, want [github.com/user/repo]", result.Unavailable) + } +} + +func TestProjectSyncAll_DryRun(t *testing.T) { + svc, ps, repoDir, id := newPushedProject(t, ".todo/**", ".todo/a.md") + parent := filepath.Dir(repoDir) + + newFile := filepath.Join(repoDir, ".todo", "b.md") + testhelpers.MakeFile(t, newFile, "b\n") + + if _, err := ps.ProjectCacheDiscover(context.Background(), []string{parent}); err != nil { + t.Fatalf("ProjectCacheDiscover: %v", err) + } + + before := len(testhelpers.GitLog(t, svc.RepoPath())) + result, err := ps.ProjectSyncAll(context.Background(), true, false, false) + if err != nil { + t.Fatalf("ProjectSyncAll: %v", err) + } + if len(result.Results) != 1 { + t.Fatalf("results = %d, want 1", len(result.Results)) + } + if len(result.Results[0].Synced) != 1 || result.Results[0].Synced[0] != ".todo/b.md" { + t.Errorf("synced = %v, want [.todo/b.md]", result.Results[0].Synced) + } + + if testhelpers.FileExists(t, filepath.Join(svc.RepoPath(), "projects", id, ".todo", "b.md")) { + t.Error("expected dry-run to leave storage untouched") + } + if after := len(testhelpers.GitLog(t, svc.RepoPath())); after != before { + t.Errorf("expected no commit in dry-run, log grew from %d to %d", before, after) + } +} + +func TestProjectSyncAll_NoStoredProjects(t *testing.T) { + svc, home := testhelpers.TestHome(t) + scanDir := filepath.Join(home, "scan") + testhelpers.MakeDir(t, scanDir) + ps := service.NewProjectService(svc) + + result, err := ps.ProjectSyncAll(context.Background(), false, false, false) + if err != nil { + t.Fatalf("ProjectSyncAll: %v", err) + } + if len(result.Results) != 0 || len(result.Unavailable) != 0 { + t.Errorf("unexpected results: %+v", result) + } +} + +func initProjectRepoWithRemote(t *testing.T, dir, remote string) { + t.Helper() + testhelpers.InitGitRepo(t, dir) + if out, err := exec.Command("git", "-C", dir, "remote", "add", "origin", remote).CombinedOutput(); err != nil { + t.Fatalf("git remote add: %v\n%s", err, out) + } + readme := filepath.Join(dir, "README.md") + if err := os.WriteFile(readme, []byte("# repo\n"), 0o644); err != nil { + t.Fatal(err) + } + cmds := [][]string{ + {"git", "-C", dir, "add", "."}, + {"git", "-C", dir, "commit", "-m", "init"}, + } + for _, args := range cmds { + if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { + t.Fatalf("%v: %v\n%s", args, err, out) + } + } +} + func TestProjectRemove_RestoresFilesAndDropsStorage(t *testing.T) { svc, ps, repoDir, id := newPushedProject(t, ".cursor/**", ".cursor/rules.md", ".cursor/extra.md") storageDir := filepath.Join(svc.RepoPath(), "projects", id) From d3a0d7fe586bdb0f51c47c3986f5b19aacee15bc Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Tue, 25 Aug 2026 14:49:20 -0500 Subject: [PATCH 2/4] bug: removed empty pattern matches since lnkinclude results --- service/doctor_project.go | 92 ---------------------------------- service/doctor_project_test.go | 30 ----------- 2 files changed, 122 deletions(-) diff --git a/service/doctor_project.go b/service/doctor_project.go index bc39b8e..a90a440 100644 --- a/service/doctor_project.go +++ b/service/doctor_project.go @@ -8,8 +8,6 @@ import ( "path/filepath" "slices" "strings" - - "github.com/polymorcodeus/lnk/internal/patterns" ) // scanProjectIssues runs project-scope health checks and returns any findings. @@ -45,12 +43,6 @@ func (s *Service) scanProjectIssues(ctx context.Context) ([]ProjectIssue, error) } issues = append(issues, broken...) - emptyPatterns, err := s.findEmptyProjectPatterns(ctx) - if err != nil { - return nil, fmt.Errorf("find empty project patterns: %w", err) - } - issues = append(issues, emptyPatterns...) - return issues, nil } @@ -233,87 +225,3 @@ func (s *Service) findBrokenProjectSymlinks(ctx context.Context) ([]ProjectIssue }) return issues, nil } - -// findEmptyProjectPatterns reports .lnkinclude patterns that match no files in -// the available project checkouts recorded in .lnkprojectcache. -func (s *Service) findEmptyProjectPatterns(ctx context.Context) ([]ProjectIssue, error) { - ps := NewProjectService(s) - check, err := ps.CheckProjectCache(ctx) - if err != nil { - return nil, err - } - - global, _ := patterns.Load(filepath.Join(s.repoPath, ".lnkinclude")) - - var issues []ProjectIssue - for _, entry := range check.Available { - id := entry.ID - path := entry.Path - manifest := filepath.Join(path, ".lnkinclude") - - local, err := patterns.Load(manifest) - if err != nil { - continue - } - allPatterns := append(global, local...) - - patternMatches := make(map[string]int) - for _, p := range allPatterns { - if p == "" || strings.HasPrefix(p, "#") || strings.HasPrefix(p, "!") { - continue - } - patternMatches[p] = 0 - } - if len(patternMatches) == 0 { - continue - } - - _ = filepath.Walk(path, func(filePath string, fileInfo os.FileInfo, err error) error { - if err != nil || fileInfo.IsDir() { - return nil - } - - rel, err := filepath.Rel(path, filePath) - if err != nil { - return nil - } - rel = filepath.ToSlash(rel) - if implicitlyExcluded(rel) { - return nil - } - - for _, p := range allPatterns { - match, err := patterns.Match([]string{p}, rel) - if err != nil { - return nil - } - if match { - patternMatches[p]++ - } - } - return nil - }) - - for pattern, count := range patternMatches { - if count > 0 { - continue - } - issues = append(issues, ProjectIssue{ - ProjectID: id, - Issue: fmt.Sprintf("pattern matches no files: %q", pattern), - Severity: "warning", - Suggestion: fmt.Sprintf( - "check the pattern in %s or remove it if no longer needed", - manifest), - }) - } - } - - slices.SortFunc(issues, func(a, b ProjectIssue) int { - if cmp := strings.Compare(a.ProjectID, b.ProjectID); cmp != 0 { - return cmp - } - return strings.Compare(a.Issue, b.Issue) - }) - return issues, nil -} diff --git a/service/doctor_project_test.go b/service/doctor_project_test.go index 85b3103..b749809 100644 --- a/service/doctor_project_test.go +++ b/service/doctor_project_test.go @@ -82,36 +82,6 @@ func TestDoctor_ProjectIssues_BrokenSymlink(t *testing.T) { } } -func TestDoctor_ProjectIssues_EmptyPattern(t *testing.T) { - svc, home := testhelpers.TestHome(t) - - projectDir := filepath.Join(home, "projects", "empty") - if err := os.MkdirAll(projectDir, 0o755); err != nil { - t.Fatal(err) - } - testhelpers.InitGitRepo(t, projectDir) - if out, err := execGit(t, projectDir, "remote", "add", "origin", "git@github.com:alice/empty.git"); err != nil { - t.Fatalf("git remote add: %v\n%s", err, out) - } - if err := os.WriteFile(filepath.Join(projectDir, ".lnkinclude"), []byte(".nonexistent/**\n"), 0o644); err != nil { - t.Fatal(err) - } - - ps := service.NewProjectService(svc) - if _, err := ps.ProjectPush(context.Background(), projectDir, false); err != nil { - t.Fatalf("ProjectPush: %v", err) - } - - report, err := svc.Doctor(context.Background(), "", false, false, false) - if err != nil { - t.Fatalf("Doctor: %v", err) - } - - if !hasProjectIssue(report.ProjectIssues, "github.com/alice/empty", "pattern matches no files") { - t.Errorf("ProjectIssues = %v, expected empty pattern issue", report.ProjectIssues) - } -} - func TestDoctor_ProjectIssues_NoIssues(t *testing.T) { svc, home := testhelpers.TestHome(t) From 7ce02f22bcc07a11667245d1bdd444e32423d9c3 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Tue, 25 Aug 2026 15:01:23 -0500 Subject: [PATCH 3/4] docs: update README --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a8083ad..671e9ed 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ lnk doctor --fix --prune-empty # also remove empty host scopes and pr lnk doctor --all # check all scopes ``` -`lnk doctor` checks project scope as well as host/common scope: it reports orphaned project storage, broken project symlinks, and `.lnkinclude` patterns that match no files. Project issues are listed with severity and a suggested fix. +`lnk doctor` checks project scope as well as host/common scope: it reports orphaned project storage, broken project symlinks, and missing project checkouts using the machine-local `.lnkprojectcache`. Project issues are listed with severity and a suggested fix. When restoring symlinks, if a real file exists at the target location (not a symlink), it will be renamed to `.lnk-backup` to preserve your data before the symlink is created. Check for `.lnk-backup` files after running `restore`, `update`, or `doctor` if you expect them. The git hook path (`lnk hooks run ...`) is collision-safe and does not create `.lnk-backup` files; it reports collisions to stderr and leaves the real file in place. @@ -200,7 +200,9 @@ lnk project list # show effective global + local patter lnk project list --all # list stored projects and file counts lnk project push # move matches to lnk storage and symlink back lnk project sync # reconcile patterns, live files, and storage +lnk project sync --all # reconcile every stored project lnk project sync --prune-deletions # also drop storage for files deleted locally +lnk project cache --scan ~/code # discover local checkouts and update .lnkprojectcache lnk project restore # recreate symlinks from storage lnk project restore --dry-run # preview what would be restored lnk project pull # pull lnk repo and restore @@ -223,6 +225,8 @@ lnk project untrack --global AGENTS.md # remove the global pattern Matched files are stored under `projects///` in your lnk repo (derived from the project's origin remote) and symlinked back into the project. Existing files at symlink locations are backed up to `.lnk-backup` during restore, just like host/common scope restores. +Project checkouts are tracked in a machine-local `.lnkprojectcache` file inside the lnk repo. The cache is updated automatically on `project push` and `project sync`, and is used by `project sync --all` and `lnk doctor` to find local projects without scanning `$HOME`. It is gitignored so absolute paths are not synced across machines. + `lnk update` and `lnk restore` automatically detect project scope when they run inside a git repo that contains a `.lnkinclude` file. The project scope is restored alongside the common and host scopes, and a `(project scope: )` message is printed to stderr. Use the global `--no-project` flag to skip automatic detection. ### Notes and edge cases @@ -232,6 +236,7 @@ Matched files are stored under `projects///` in your ln - **Files tracked by the project's own git are left alone.** If a match is committed upstream (a typical `AGENTS.md`), push/sync skip it with a warning to avoid replacing a committed file with a machine-local symlink; use `--force` to override. - **The lnk repo protects itself.** Project commands refuse to run inside the lnk repository (or any clone of it) to prevent storing it inside its own storage. - **Reconciliation is explicit for deletions.** `project sync` reports stored files whose live copies were deleted; they are only removed from storage with `--prune-deletions`. +- **`.lnkprojectcache` is machine-local.** The cache is maintained automatically by `project push` and `project sync`. Use `project cache --scan ` to populate or repair it on a new machine or after moving checkouts. ### Hooks @@ -283,7 +288,8 @@ man man/lnk-project-push.1 # read a generated page | `project list` | Show effective project patterns | | `project untrack [--keep] ` | Remove a pattern from the project's `.lnkinclude`, restoring its files unless `--keep` | | `project push [--force]` | Move matching project files to lnk storage | -| `project sync [--dry-run] [--prune-deletions] [--force]` | Reconcile patterns, live files, and storage | +| `project sync [--all] [--dry-run] [--prune-deletions] [--force]` | Reconcile patterns, live files, and storage | +| `project cache --scan ` | Discover local checkouts and update `.lnkprojectcache` | | `project restore [--dry-run] [--force]` | Recreate project symlinks from storage | | `project pull [--force]` | Pull lnk repo and restore project symlinks | | `project remove` | Stop managing the project: restore all files and delete storage | From 5faa3bd296d46ff195db9ab827e6ba458aba30e2 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Tue, 25 Aug 2026 15:01:53 -0500 Subject: [PATCH 4/4] chore: version bump --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index a4b6ac3..b1d18bc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v2.2.0 +v2.3.0