From c272ac4c5f085dd7efbb25de52ca9007b5aa6452 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sun, 23 Aug 2026 15:40:12 -0500 Subject: [PATCH 1/5] feat: add implicit project-scope detection to update and restore --- cmd/root.go | 7 +- service/integration.go | 125 +++++++++++++++++ service/integration_test.go | 266 ++++++++++++++++++++++++++++++++++++ service/service.go | 18 +++ 4 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 service/integration.go create mode 100644 service/integration_test.go diff --git a/cmd/root.go b/cmd/root.go index d62b97f..ee23ae2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -37,6 +37,7 @@ func NewRootCommand() *cobra.Command { } rootCmd.PersistentFlags().StringVar(&repoPath, "repo", "", "path to the lnk repository") + rootCmd.PersistentFlags().Bool("no-project", false, "skip automatic project scope detection") rootCmd.AddCommand(newInitCmd(&repoPath)) rootCmd.AddCommand(newCloneCmd(&repoPath)) @@ -913,7 +914,8 @@ func newRestoreCmd(repoFlag *string) *cobra.Command { Short: "Restore the effective machine profile", RunE: func(cmd *cobra.Command, args []string) error { app := svc(repoFlag) - info, err := app.Restore(cmd.Context(), host, dryRun) + noProject, _ := cmd.Flags().GetBool("no-project") + info, err := app.RestoreWithProject(cmd.Context(), host, noProject, dryRun) if err != nil { return err } @@ -935,7 +937,8 @@ func newUpdateCmd(repoFlag *string) *cobra.Command { Short: "Pull repo changes and restore the effective machine profile", RunE: func(cmd *cobra.Command, args []string) error { app := svc(repoFlag) - info, err := app.Update(cmd.Context(), host) + noProject, _ := cmd.Flags().GetBool("no-project") + info, err := app.UpdateWithProject(cmd.Context(), host, noProject) if err != nil { return err } diff --git a/service/integration.go b/service/integration.go new file mode 100644 index 0000000..cfe8856 --- /dev/null +++ b/service/integration.go @@ -0,0 +1,125 @@ +package service + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/polymorcodeus/lnk/internal/gitboundary" + "github.com/polymorcodeus/lnk/internal/resolver" + "github.com/polymorcodeus/lnk/internal/scope" +) + +// DetectProjectScope checks if dir (or the current working directory when dir +// is empty) is inside a git repository that contains a .lnkinclude file. When +// a project scope is detected, it returns the project root, project ID, and a +// resolver that maps between live project paths and lnk storage paths. If dir +// is not inside a project with .lnkinclude, ok is false and err is nil. The +// lnk repository itself is never treated as a project. +func (s *Service) DetectProjectScope(ctx context.Context, dir string) (root, id string, resolver scope.Resolver, ok bool, err error) { + if dir == "" { + var wdErr error + dir, wdErr = os.Getwd() + if wdErr != nil { + return "", "", nil, false, fmt.Errorf("get working directory: %w", wdErr) + } + } + + absDir, err := filepath.Abs(dir) + if err != nil { + return "", "", nil, false, fmt.Errorf("resolve path %s: %w", dir, err) + } + + inside, gitRoot, err := gitboundary.IsInsideGitRepo(ctx, absDir) + if err != nil { + return "", "", nil, false, err + } + if !inside { + return "", "", nil, false, nil + } + + if s.isLnkRepoRoot(gitRoot) { + return "", "", nil, false, nil + } + + manifest := filepath.Join(gitRoot, ".lnkinclude") + if _, statErr := os.Stat(manifest); errors.Is(statErr, os.ErrNotExist) { + return "", "", nil, false, nil + } else if statErr != nil { + return "", "", nil, false, fmt.Errorf("check .lnkinclude: %w", statErr) + } + + id, err = resolveProjectID(ctx, gitRoot) + if err != nil { + return "", "", nil, false, fmt.Errorf("resolve project id: %w", err) + } + + storageDir := filepath.Join(s.repoPath, "projects", id) + resolver = &scope.ProjectRootResolver{ + GitRoot: gitRoot, + StorageDir: storageDir, + } + + return gitRoot, id, resolver, true, nil +} + +// resolveProjectID returns the stable storage identifier for the git repo +// rooted at root. Repositories without an origin remote fall back to a local +// path-derived identifier. +func resolveProjectID(ctx context.Context, root string) (string, error) { + id, err := resolver.ResolveProjectID(ctx, root) + if errors.Is(err, resolver.ErrNoOrigin) { + return resolver.LocalProjectID(root), nil + } + if err != nil { + return "", err + } + return id, nil +} + +// RestoreWithProject restores the effective machine profile and, when +// noProject is false, automatically detects and restores any project scope +// for the current working directory. +func (s *Service) RestoreWithProject(ctx context.Context, host string, noProject, dryRun bool) (RestoreInfo, error) { + info, err := s.Restore(ctx, host, dryRun) + if err != nil { + return RestoreInfo{}, err + } + + if noProject { + return info, nil + } + + root, id, _, ok, err := s.DetectProjectScope(ctx, "") + if err != nil { + return RestoreInfo{}, err + } + if !ok { + return info, nil + } + + fmt.Fprintf(os.Stderr, "(project scope: %s)\n", id) + + projectInfo, err := NewProjectService(s).ProjectRestore(ctx, root, dryRun, false) + if err != nil { + return RestoreInfo{}, err + } + + info.Restored = append(info.Restored, projectInfo.Restored...) + info.BackedUp = append(info.BackedUp, projectInfo.BackedUp...) + info.SkippedTracked = append(info.SkippedTracked, projectInfo.SkippedTracked...) + info.SkippedUnmatched = append(info.SkippedUnmatched, projectInfo.SkippedUnmatched...) + + return info, nil +} + +// UpdateWithProject pulls repo changes and then restores the effective machine +// profile, including any detected project scope unless noProject is true. +func (s *Service) UpdateWithProject(ctx context.Context, host string, noProject bool) (RestoreInfo, error) { + if err := s.Pull(ctx); err != nil { + return RestoreInfo{}, err + } + return s.RestoreWithProject(ctx, host, noProject, false) +} diff --git a/service/integration_test.go b/service/integration_test.go new file mode 100644 index 0000000..9c76366 --- /dev/null +++ b/service/integration_test.go @@ -0,0 +1,266 @@ +package service_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/polymorcodeus/lnk/internal/testhelpers" + "github.com/polymorcodeus/lnk/service" +) + +func TestDetectProjectScope_OutsideGitRepo(t *testing.T) { + svc, _ := testhelpers.TestHome(t) + + root, id, resolver, ok, err := svc.DetectProjectScope(context.Background(), t.TempDir()) + if err != nil { + t.Fatalf("DetectProjectScope: %v", err) + } + if ok { + t.Fatalf("expected no project scope outside git repo, got root=%q id=%q", root, id) + } + if resolver != nil { + t.Fatal("expected nil resolver outside git repo") + } +} + +func TestDetectProjectScope_GitRepoWithoutInclude(t *testing.T) { + svc, _ := testhelpers.TestHome(t) + + projectDir := t.TempDir() + testhelpers.InitGitRepo(t, projectDir) + + _, _, _, ok, err := svc.DetectProjectScope(context.Background(), projectDir) + if err != nil { + t.Fatalf("DetectProjectScope: %v", err) + } + if ok { + t.Fatal("expected no project scope without .lnkinclude") + } +} + +func TestDetectProjectScope_WithInclude(t *testing.T) { + svc, _ := testhelpers.TestHome(t) + + projectDir := t.TempDir() + testhelpers.InitGitRepo(t, projectDir) + if out, err := execGit(t, projectDir, "remote", "add", "origin", "git@github.com:alice/project.git"); err != nil { + t.Fatalf("git remote add: %v\n%s", err, out) + } + if err := os.WriteFile(filepath.Join(projectDir, ".lnkinclude"), []byte("agents.md\n"), 0o644); err != nil { + t.Fatal(err) + } + + root, id, resolver, ok, err := svc.DetectProjectScope(context.Background(), projectDir) + if err != nil { + t.Fatalf("DetectProjectScope: %v", err) + } + if !ok { + t.Fatal("expected project scope detected") + } + canonicalProjectDir, err := filepath.EvalSymlinks(projectDir) + if err != nil { + t.Fatal(err) + } + if root != canonicalProjectDir { + t.Errorf("root = %q, want %q", root, canonicalProjectDir) + } + if id != "github.com/alice/project" { + t.Errorf("id = %q, want github.com/alice/project", id) + } + if resolver == nil { + t.Fatal("expected non-nil resolver") + } +} + +func TestDetectProjectScope_LnkRepoNotProject(t *testing.T) { + svc, _ := testhelpers.TestHome(t) + + root, id, _, ok, err := svc.DetectProjectScope(context.Background(), svc.RepoPath()) + if err != nil { + t.Fatalf("DetectProjectScope: %v", err) + } + if ok { + t.Fatalf("expected lnk repo not to be treated as project, got root=%q id=%q", root, id) + } +} + +func TestRestoreWithProject_DetectsAndRestores(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoPath := svc.RepoPath() + + // Set up a common-scope file so Restore has non-project work to do. + _, commonLive := setupTrackedFile(t, repoPath, home, "common", ".bashrc", "# bashrc") + if err := os.Remove(commonLive); err != nil { + t.Fatal(err) + } + + projectDir := t.TempDir() + testhelpers.InitGitRepo(t, projectDir) + if out, err := execGit(t, projectDir, "remote", "add", "origin", "git@github.com:alice/project.git"); err != nil { + t.Fatalf("git remote add: %v\n%s", err, out) + } + if err := os.WriteFile(filepath.Join(projectDir, ".lnkinclude"), []byte("agents.md\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectDir, "agents.md"), []byte("agents"), 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) + } + + projectLive := filepath.Join(projectDir, "agents.md") + if err := os.Remove(projectLive); err != nil { + t.Fatal(err) + } + + oldWd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(projectDir); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(oldWd); err != nil { + t.Fatalf("restore working directory: %v", err) + } + }() + + info, err := svc.RestoreWithProject(context.Background(), "", false, false) + if err != nil { + t.Fatalf("RestoreWithProject: %v", err) + } + + if !contains(info.Restored, ".bashrc") { + t.Errorf("Restored = %v, expected to contain .bashrc", info.Restored) + } + if !contains(info.Restored, "agents.md") { + t.Errorf("Restored = %v, expected to contain agents.md", info.Restored) + } + + testhelpers.AssertSymlink(t, commonLive, filepath.Join(repoPath, "common.lnk", ".bashrc")) + testhelpers.AssertSymlink(t, projectLive, filepath.Join(repoPath, "projects", "github.com", "alice", "project", "agents.md")) +} + +func TestRestoreWithProject_NoProjectOptOut(t *testing.T) { + svc, _ := testhelpers.TestHome(t) + + projectDir := t.TempDir() + testhelpers.InitGitRepo(t, projectDir) + if out, err := execGit(t, projectDir, "remote", "add", "origin", "git@github.com:alice/project.git"); err != nil { + t.Fatalf("git remote add: %v\n%s", err, out) + } + if err := os.WriteFile(filepath.Join(projectDir, ".lnkinclude"), []byte("agents.md\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectDir, "agents.md"), []byte("agents"), 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) + } + + projectLive := filepath.Join(projectDir, "agents.md") + if err := os.Remove(projectLive); err != nil { + t.Fatal(err) + } + + oldWd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(projectDir); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(oldWd); err != nil { + t.Fatalf("restore working directory: %v", err) + } + }() + + _, err = svc.RestoreWithProject(context.Background(), "", true, false) + if err != nil { + t.Fatalf("RestoreWithProject(noProject=true): %v", err) + } + + if _, err := os.Lstat(projectLive); !os.IsNotExist(err) { + t.Fatal("project symlink created despite --no-project") + } +} + +func TestRestoreWithProject_FromSubdirectory(t *testing.T) { + svc, _ := testhelpers.TestHome(t) + + projectDir := t.TempDir() + testhelpers.InitGitRepo(t, projectDir) + if out, err := execGit(t, projectDir, "remote", "add", "origin", "git@github.com:alice/project.git"); err != nil { + t.Fatalf("git remote add: %v\n%s", err, out) + } + if err := os.WriteFile(filepath.Join(projectDir, ".lnkinclude"), []byte("agents.md\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectDir, "agents.md"), []byte("agents"), 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) + } + + projectLive := filepath.Join(projectDir, "agents.md") + if err := os.Remove(projectLive); err != nil { + t.Fatal(err) + } + + subDir := filepath.Join(projectDir, "sub") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatal(err) + } + + oldWd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(subDir); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(oldWd); err != nil { + t.Fatalf("restore working directory: %v", err) + } + }() + + if _, err := svc.RestoreWithProject(context.Background(), "", false, false); err != nil { + t.Fatalf("RestoreWithProject: %v", err) + } + + testhelpers.AssertSymlink(t, projectLive, filepath.Join(svc.RepoPath(), "projects", "github.com", "alice", "project", "agents.md")) +} + +func execGit(t *testing.T, dir string, args ...string) ([]byte, error) { + t.Helper() + cmd := append([]string{"-C", dir}, args...) + return osExec("git", cmd...) +} + +func osExec(name string, args ...string) ([]byte, error) { + return exec.Command(name, args...).CombinedOutput() +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/service/service.go b/service/service.go index 95eddad..c955b28 100644 --- a/service/service.go +++ b/service/service.go @@ -470,3 +470,21 @@ func (s *Service) IsLnkRepository() bool { return s.hasLnkMarker() } + +// isLnkRepoRoot reports whether root is the configured lnk repository or +// carries the .lnkrepo marker. This prevents implicit project detection from +// treating the lnk repo itself as a project. +func (s *Service) isLnkRepoRoot(root string) bool { + if _, err := os.Stat(filepath.Join(root, repoMarkerFile)); err == nil { + return true + } + repoPath, err := filepath.EvalSymlinks(s.repoPath) + if err != nil { + return false + } + canonicalRoot, err := filepath.EvalSymlinks(root) + if err != nil { + return false + } + return repoPath == canonicalRoot +} From 96b6fcc1c8b9eb02eae9a69925c9c60b642985e9 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sun, 23 Aug 2026 15:47:03 -0500 Subject: [PATCH 2/5] feat: add project-scope health checks to lnk doctor --- cmd/root.go | 18 ++ service/doctor.go | 17 +- service/doctor_project.go | 316 +++++++++++++++++++++++++++++++++ service/doctor_project_test.go | 149 ++++++++++++++++ service/integration_test.go | 8 +- 5 files changed, 501 insertions(+), 7 deletions(-) create mode 100644 service/doctor_project.go create mode 100644 service/doctor_project_test.go diff --git a/cmd/root.go b/cmd/root.go index ee23ae2..70a9ca9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1144,6 +1144,24 @@ func printDoctor(w io.Writer, report service.DoctorReport) error { } } } + if len(report.ProjectIssues) > 0 { + if _, err := fmt.Fprintln(w, "Project issues:"); err != nil { + return err + } + for _, issue := range report.ProjectIssues { + if _, err := fmt.Fprintf(w, " [%s] %s: %s", issue.Severity, issue.ProjectID, issue.Issue); err != nil { + return err + } + if issue.Suggestion != "" { + if _, err := fmt.Fprintf(w, " -> %s", issue.Suggestion); err != nil { + return err + } + } + if _, err := fmt.Fprintln(w); err != nil { + return err + } + } + } if len(report.PrunedProjects) > 0 { if _, err := fmt.Fprintln(w, "Pruned empty project storage:"); err != nil { return err diff --git a/service/doctor.go b/service/doctor.go index f59d3a5..6ee03be 100644 --- a/service/doctor.go +++ b/service/doctor.go @@ -61,6 +61,15 @@ type DoctorReport struct { UnmarkedProjects []string // storage under projects/ without a marker EmptyProjects []string // marked projects with no stored files PrunedProjects []string // empty project storage removed by --fix --prune-empty + ProjectIssues []ProjectIssue +} + +// ProjectIssue captures a project-scope health finding for doctor. +type ProjectIssue struct { + ProjectID string + Issue string + Severity string // "error" or "warning" + Suggestion string } // HasIssues reports whether the doctor found actionable issues. @@ -68,7 +77,7 @@ func (r DoctorReport) HasIssues() bool { if r.MarkerMissing || len(r.Collisions) > 0 || len(r.EmptyScopes) > 0 { return true } - if len(r.UnmarkedProjects) > 0 || len(r.EmptyProjects) > 0 { + if len(r.UnmarkedProjects) > 0 || len(r.EmptyProjects) > 0 || len(r.ProjectIssues) > 0 { return true } for _, result := range r.ScopeResults { @@ -182,6 +191,12 @@ func (s *Service) doctorScan(ctx context.Context, host string, all bool) (Doctor report.UnmarkedProjects = unmarked report.EmptyProjects = emptyProjects + projectIssues, err := s.scanProjectIssues(ctx) + if err != nil { + return DoctorReport{}, err + } + report.ProjectIssues = projectIssues + return report, nil } diff --git a/service/doctor_project.go b/service/doctor_project.go new file mode 100644 index 0000000..65525a5 --- /dev/null +++ b/service/doctor_project.go @@ -0,0 +1,316 @@ +package service + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "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 + + orphaned, err := s.findOrphanedProjectStorage(ctx) + if err != nil { + return nil, fmt.Errorf("find orphaned project storage: %w", err) + } + issues = append(issues, orphaned...) + + broken, err := s.findBrokenProjectSymlinks(ctx) + if err != nil { + return nil, fmt.Errorf("find broken project symlinks: %w", err) + } + 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 +} + +// findOrphanedProjectStorage returns issues for stored projects whose ID does +// not match any live project directory (identified by a .lnkinclude file). +func (s *Service) findOrphanedProjectStorage(ctx context.Context) ([]ProjectIssue, error) { + stored, err := s.storedProjectIDs() + if err != nil { + return nil, err + } + if len(stored) == 0 { + return nil, nil + } + + home, err := s.homeDir() + 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 + } + + var issues []ProjectIssue + for _, id := range stored { + if _, ok := liveIDs[id]; ok { + continue + } + issues = append(issues, ProjectIssue{ + ProjectID: id, + Issue: "orphaned project storage with no corresponding repo on disk", + Severity: "warning", + Suggestion: fmt.Sprintf( + "run 'rm -rf %s' or verify the project is still needed", + filepath.Join(s.repoPath, "projects", id)), + }) + } + + slices.SortFunc(issues, func(a, b ProjectIssue) int { + return strings.Compare(a.ProjectID, b.ProjectID) + }) + return issues, nil +} + +// storedProjectIDs returns the IDs of all projects stored under projects/. +func (s *Service) storedProjectIDs() ([]string, error) { + root := filepath.Join(s.repoPath, "projects") + if _, err := os.Stat(root); errors.Is(err, os.ErrNotExist) { + return nil, nil + } else if err != nil { + return nil, err + } + + ids := make(map[string]struct{}) + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || info.Name() != projectMarkerFile { + return nil + } + id, err := filepath.Rel(root, filepath.Dir(path)) + if err != nil { + return err + } + ids[filepath.ToSlash(id)] = struct{}{} + return nil + }) + if err != nil { + return nil, err + } + + result := make([]string, 0, len(ids)) + for id := range ids { + result = append(result, id) + } + slices.Sort(result) + return result, nil +} + +// findBrokenProjectSymlinks walks live project directories and reports +// project-scope symlinks whose storage target no longer exists. +func (s *Service) findBrokenProjectSymlinks(ctx context.Context) ([]ProjectIssue, error) { + home, err := s.homeDir() + 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 + } + storageDir := filepath.Join(s.repoPath, "projects", id) + + _ = filepath.Walk(path, func(livePath string, liveInfo os.FileInfo, err error) error { + if err != nil || liveInfo.IsDir() { + return nil + } + if liveInfo.Mode()&os.ModeSymlink == 0 { + return nil + } + if !isStorageSymlink(livePath, storageDir) { + return nil + } + + target, err := os.Readlink(livePath) + if err != nil { + return nil + } + if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) { + return nil + } + + rel, err := filepath.Rel(path, livePath) + if err != nil { + return nil + } + + issues = append(issues, ProjectIssue{ + ProjectID: id, + Issue: fmt.Sprintf("broken symlink: %s", filepath.ToSlash(rel)), + Severity: "error", + Suggestion: "run 'lnk project restore' from the project directory", + }) + return nil + }) + + return nil + }) + if err != nil { + return nil, err + } + + 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 +} + +// findEmptyProjectPatterns reports .lnkinclude patterns that match no files in +// the project directory. +func (s *Service) findEmptyProjectPatterns(ctx context.Context) ([]ProjectIssue, error) { + home, err := s.homeDir() + 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 + } + + global, _ := patterns.Load(filepath.Join(s.repoPath, ".lnkinclude")) + local, err := patterns.Load(manifest) + if err != nil { + return nil + } + 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 { + return nil + } + + _ = 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), + }) + } + + return nil + }) + if err != nil { + return nil, err + } + + 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 +} + +// 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 new file mode 100644 index 0000000..8db8a2b --- /dev/null +++ b/service/doctor_project_test.go @@ -0,0 +1,149 @@ +package service_test + +import ( + "context" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/polymorcodeus/lnk/internal/testhelpers" + "github.com/polymorcodeus/lnk/service" +) + +func TestDoctor_ProjectIssues_OrphanedStorage(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoPath := svc.RepoPath() + + // Create a stored project that has no live repo on disk. + orphanedStorage := filepath.Join(repoPath, "projects", "github.com", "alice", "orphaned") + if err := os.MkdirAll(orphanedStorage, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(orphanedStorage, ".lnkproject"), []byte("github.com/alice/orphaned\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(orphanedStorage, "config"), []byte("config"), 0o644); err != nil { + t.Fatal(err) + } + + report, err := svc.Doctor(context.Background(), "", false, false, false) + if err != nil { + t.Fatalf("Doctor: %v", err) + } + + 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) + } + _ = home +} + +func TestDoctor_ProjectIssues_BrokenSymlink(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoPath := svc.RepoPath() + + projectDir := filepath.Join(home, "projects", "myapp") + 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/myapp.git"); err != nil { + t.Fatalf("git remote add: %v\n%s", err, out) + } + if err := os.WriteFile(filepath.Join(projectDir, ".lnkinclude"), []byte("agents.md\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectDir, "agents.md"), []byte("agents"), 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) + } + + // Break the symlink by removing its storage target. + storageTarget := filepath.Join(repoPath, "projects", "github.com", "alice", "myapp", "agents.md") + if err := os.Remove(storageTarget); err != nil { + t.Fatal(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/myapp", "broken symlink") { + t.Errorf("ProjectIssues = %v, expected broken symlink issue", report.ProjectIssues) + } +} + +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) + } + + 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) + + projectDir := filepath.Join(home, "projects", "healthy") + 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/healthy.git"); err != nil { + t.Fatalf("git remote add: %v\n%s", err, out) + } + if err := os.WriteFile(filepath.Join(projectDir, ".lnkinclude"), []byte("agents.md\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectDir, "agents.md"), []byte("agents"), 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) + } + + for _, issue := range report.ProjectIssues { + if issue.ProjectID == "github.com/alice/healthy" { + t.Fatalf("unexpected project issue for healthy project: %v", issue) + } + } +} + +func hasProjectIssue(issues []service.ProjectIssue, projectID, issueSubstr string) bool { + return slices.ContainsFunc(issues, func(i service.ProjectIssue) bool { + return i.ProjectID == projectID && strings.Contains(i.Issue, issueSubstr) + }) +} diff --git a/service/integration_test.go b/service/integration_test.go index 9c76366..5e8d54c 100644 --- a/service/integration_test.go +++ b/service/integration_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "testing" "github.com/polymorcodeus/lnk/internal/testhelpers" @@ -257,10 +258,5 @@ func osExec(name string, args ...string) ([]byte, error) { } func contains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false + return slices.Contains(slice, item) } From 84e8587e1562267d7ffae84a2f638fc47dfc1419 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sun, 23 Aug 2026 15:48:49 -0500 Subject: [PATCH 3/5] chore: version bump --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 46b105a..1defe53 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v2.0.0 +v2.1.0 From 56437a0453994afa01302204f5c5c613bed40c41 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sun, 23 Aug 2026 15:56:36 -0500 Subject: [PATCH 4/5] docs: updated readme --- README.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 140709e..6537b90 100644 --- a/README.md +++ b/README.md @@ -157,10 +157,12 @@ lnk list --all # all scopes ```bash lnk doctor # audit repo and profile health lnk doctor --fix # apply safe automatic fixes -lnk doctor --fix --prune-empty # also remove empty host scopes +lnk doctor --fix --prune-empty # also remove empty host scopes and project storage 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. + 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. ### Format migration @@ -207,6 +209,12 @@ lnk project untrack --keep .crush/** # remove a pattern but leave files man lnk project remove # stop managing the project, restore all files lnk project forget # stop managing the project, keep stored files +# implicit detection in update/restore +lnk update # also restores any detected project scope +lnk restore # also restores any detected project scope +lnk update --no-project # skip automatic project-scope detection +lnk restore --no-project # skip automatic project-scope detection + # global patterns (apply to every project) lnk project add --global AGENTS.md # include AGENTS.md everywhere lnk project add '!AGENTS.md' # then exclude it in one project @@ -215,6 +223,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. +`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 - **Global patterns are hand-managed** (or edited via `--global`): they apply to every project, so negate them per project with a local `!` pattern. Quote the `!` in your shell (`'!AGENTS.md'`) or zsh's history expansion will eat it before lnk sees it. @@ -248,9 +258,9 @@ man man/lnk-project-push.1 # read a generated page | `commit [-m message]` | Stage all changes and commit | | `push` | Push existing commits | | `pull` | Pull repo changes | -| `restore [--host H] [--dry-run]` | Restore symlinks without pulling | -| `update [--host H]` | Pull and restore the effective profile | -| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health | +| `restore [--host H] [--dry-run] [--no-project]` | Restore symlinks without pulling (auto-detects project scope) | +| `update [--host H] [--no-project]` | Pull and restore the effective profile (auto-detects project scope) | +| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health, including project scope | | `format [--v1 \| --v2]` | Migrate repo format | | `bootstrap` | Run bootstrap.sh explicitly | | `project init` | Activate project scope in the current git repo | @@ -271,6 +281,7 @@ Available with all commands: | Option | Default | What it does | | --- | --- | --- | | `--repo ` | `~/.config/lnk` | Path to the lnk repository | +| `--no-project` | `false` | Skip automatic project-scope detection | ## Acknowledgements From 51bddf58a1a01766d16102a8c5e68a74a910a6a9 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sun, 23 Aug 2026 16:06:46 -0500 Subject: [PATCH 5/5] bug: fixed working directory bug for doctor --- service/doctor_project.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/service/doctor_project.go b/service/doctor_project.go index 65525a5..997398e 100644 --- a/service/doctor_project.go +++ b/service/doctor_project.go @@ -174,6 +174,9 @@ func (s *Service) findBrokenProjectSymlinks(ctx context.Context) ([]ProjectIssue if err != nil { return nil } + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(livePath), target) + } if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) { return nil }