From 3decae8bcfd49149921eed03528ce3892ab80aca Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Thu, 20 Aug 2026 16:01:14 -0500 Subject: [PATCH 1/7] feat: core commands init, add, list, untrack --- cmd/root.go | 170 +++++++++++++++++++++++++++- internal/gitboundary/gitboundary.go | 2 +- service/project_add.go | 35 ------ service/project_add_test.go | 57 ---------- 4 files changed, 167 insertions(+), 97 deletions(-) delete mode 100644 service/project_add.go delete mode 100644 service/project_add_test.go diff --git a/cmd/root.go b/cmd/root.go index a716e65..cbe7758 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,10 +5,12 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "github.com/spf13/cobra" + "github.com/polymorcodeus/lnk/internal/gitboundary" "github.com/polymorcodeus/lnk/service" ) @@ -167,21 +169,181 @@ func newProjectCmd(repoFlag *string) *cobra.Command { Use: "project", Short: "Manage project-local dotfiles", } + cmd.PersistentFlags().String("dir", "", "project directory (default: current directory)") + cmd.AddCommand(newProjectInitCmd(repoFlag)) cmd.AddCommand(newProjectAddCmd(repoFlag)) + cmd.AddCommand(newProjectListCmd(repoFlag)) + cmd.AddCommand(newProjectUntrackCmd(repoFlag)) return cmd } +// projectDir resolves the --dir flag (defaulting to the current working +// directory) and validates that it is inside a git repository. +func projectDir(cmd *cobra.Command) (string, error) { + dir, _ := cmd.Flags().GetString("dir") + if dir == "" { + var err error + dir, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("get working directory: %w", err) + } + } + + absDir, err := filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("resolve path %s: %w", dir, err) + } + + inside, _, err := gitboundary.IsInsideGitRepo(cmd.Context(), absDir) + if err != nil { + return "", err + } + if !inside { + return "", fmt.Errorf("%s is not inside a git repository", absDir) + } + + return absDir, nil +} + +// newProjectInitCmd returns the "project init" subcommand. +func newProjectInitCmd(repoFlag *string) *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Activate project scope for the current repo", + RunE: func(cmd *cobra.Command, args []string) error { + projectRoot, err := projectDir(cmd) + if err != nil { + return err + } + + ps := service.NewProjectService(svc(repoFlag)) + created, err := ps.ProjectInit(cmd.Context(), projectRoot) + if err != nil { + return err + } + + if created { + _, err = fmt.Fprintln(cmd.OutOrStdout(), "Project scope initialized — global patterns apply. Add repo-local patterns with 'lnk project add '.") + } else { + _, err = fmt.Fprintln(cmd.OutOrStdout(), "Project scope already initialized.") + } + return err + }, + } +} + // newProjectAddCmd returns the "project add" subcommand. func newProjectAddCmd(repoFlag *string) *cobra.Command { - return &cobra.Command{ - Use: "add ", - Short: "Track project-local files", + cmd := &cobra.Command{ + Use: "add ", + Short: "Add a pattern to .lnkinclude for the current project", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + projectRoot, err := projectDir(cmd) + if err != nil { + return err + } + + ps := service.NewProjectService(svc(repoFlag)) + for _, pattern := range args { + normalized, err := ps.ProjectAddPattern(cmd.Context(), projectRoot, pattern) + if err != nil { + return err + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Added '%s' to .lnkinclude — remember to commit it.\n", normalized); err != nil { + return err + } + } + return nil + }, + } + return cmd +} + +// newProjectListCmd returns the "project list" subcommand. +func newProjectListCmd(repoFlag *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List effective patterns for the current project", + RunE: func(cmd *cobra.Command, args []string) error { + projectRoot, err := projectDir(cmd) + if err != nil { + return err + } + + ps := service.NewProjectService(svc(repoFlag)) + global, local, err := ps.ProjectListPatterns(cmd.Context(), projectRoot) + if err != nil { + return err + } + app := svc(repoFlag) - return app.ProjectAdd(cmd.Context(), args) + globalPath := filepath.Join(app.RepoPath(), ".lnkinclude") + localPath := filepath.Join(projectRoot, ".lnkinclude") + + if len(global) > 0 { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "# global (%s)\n", globalPath); err != nil { + return err + } + for _, p := range global { + if _, err := fmt.Fprintln(cmd.OutOrStdout(), p); err != nil { + return err + } + } + } + if len(global) > 0 && len(local) > 0 { + if _, err := fmt.Fprintln(cmd.OutOrStdout()); err != nil { + return err + } + } + if len(local) > 0 { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "# local (%s)\n", localPath); err != nil { + return err + } + for _, p := range local { + if _, err := fmt.Fprintln(cmd.OutOrStdout(), p); err != nil { + return err + } + } + } + if len(global) == 0 && len(local) == 0 { + _, err = fmt.Fprintln(cmd.OutOrStdout(), "# no patterns defined") + } + return err + }, + } + return cmd +} + +// newProjectUntrackCmd returns the "project untrack" subcommand. +func newProjectUntrackCmd(repoFlag *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "untrack ", + Short: "Remove a pattern from .lnkinclude", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + projectRoot, err := projectDir(cmd) + if err != nil { + return err + } + + ps := service.NewProjectService(svc(repoFlag)) + removed, isGlobal, err := ps.ProjectUntrackPattern(projectRoot, args[0]) + if err != nil { + return err + } + + if removed { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Removed '%s' from .lnkinclude — remember to commit it.\n", args[0]) + } else if isGlobal { + app := svc(repoFlag) + globalPath := filepath.Join(app.RepoPath(), ".lnkinclude") + _, err = fmt.Fprintf(cmd.ErrOrStderr(), "This pattern comes from the global .lnkinclude — edit %s to remove it.\n", globalPath) + } + return err }, } + return cmd } // newMoveCmd returns the "move" subcommand. diff --git a/internal/gitboundary/gitboundary.go b/internal/gitboundary/gitboundary.go index d4b2d0a..afc5e16 100644 --- a/internal/gitboundary/gitboundary.go +++ b/internal/gitboundary/gitboundary.go @@ -92,7 +92,7 @@ func IsInsideGitRepo(ctx context.Context, absPath string) (bool, string, error) if err != nil { return false, "", fmt.Errorf("relate %s to git root %s: %w", checkPath, root, err) } - if strings.HasPrefix(rel, "..") || rel == "." { + if strings.HasPrefix(rel, "..") { return false, "", nil } diff --git a/service/project_add.go b/service/project_add.go deleted file mode 100644 index 4aeb550..0000000 --- a/service/project_add.go +++ /dev/null @@ -1,35 +0,0 @@ -package service - -import ( - "context" - "fmt" - "path/filepath" - - "github.com/polymorcodeus/lnk/internal/gitboundary" - "github.com/polymorcodeus/lnk/internal/lnkerror" -) - -// ProjectAdd validates that the supplied paths are inside a git repo and -// reports that project scope is not yet implemented. -func (s *Service) ProjectAdd(ctx context.Context, paths []string) error { - if len(paths) == 0 { - return lnkerror.Wrap(lnkerror.ErrNoPaths) - } - - for _, input := range paths { - absPath, err := filepath.Abs(input) - if err != nil { - return fmt.Errorf("resolve path %s: %w", input, err) - } - - inside, _, err := gitboundary.IsInsideGitRepo(ctx, absPath) - if err != nil { - return err - } - if !inside { - return lnkerror.WithPathAndSuggestion(lnkerror.ErrOutsideGitRepo, input, "use 'lnk add' for host/common scope") - } - } - - return lnkerror.WithSuggestion(lnkerror.ErrProjectScopeNotImplemented, "this file is inside a git repo and will be trackable once project scope lands") -} diff --git a/service/project_add_test.go b/service/project_add_test.go deleted file mode 100644 index 57649af..0000000 --- a/service/project_add_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package service_test - -import ( - "context" - "errors" - "path/filepath" - "testing" - - "github.com/polymorcodeus/lnk/internal/lnkerror" - "github.com/polymorcodeus/lnk/internal/testhelpers" -) - -func TestProjectAdd_RefusesHostFile(t *testing.T) { - svc, home := testhelpers.TestHome(t) - - dotfile := filepath.Join(home, ".vimrc") - testhelpers.MakeFile(t, dotfile, "# vimrc") - - err := svc.ProjectAdd(context.Background(), []string{dotfile}) - if err == nil { - t.Fatal("expected error for host file with project add, got nil") - } - if !errors.Is(err, lnkerror.ErrOutsideGitRepo) { - t.Errorf("error = %v, want %v", err, lnkerror.ErrOutsideGitRepo) - } -} - -func TestProjectAdd_AcceptsProjectFile(t *testing.T) { - svc, home := testhelpers.TestHome(t) - - repoDir := filepath.Join(home, "repos", "hermes") - testhelpers.MakeDir(t, repoDir) - testhelpers.InitGitRepo(t, repoDir) - - projectFile := filepath.Join(repoDir, ".cursor", "rules.md") - testhelpers.MakeFile(t, projectFile, "# rules") - - err := svc.ProjectAdd(context.Background(), []string{projectFile}) - if err == nil { - t.Fatal("expected error for unimplemented project scope, got nil") - } - if !errors.Is(err, lnkerror.ErrProjectScopeNotImplemented) { - t.Errorf("error = %v, want %v", err, lnkerror.ErrProjectScopeNotImplemented) - } -} - -func TestProjectAdd_EmptyPaths(t *testing.T) { - svc, _ := testhelpers.TestHome(t) - - err := svc.ProjectAdd(context.Background(), []string{}) - if err == nil { - t.Fatal("expected error for empty paths, got nil") - } - if !errors.Is(err, lnkerror.ErrNoPaths) { - t.Errorf("error = %v, want %v", err, lnkerror.ErrNoPaths) - } -} From 0f6c9ba7f21ceb75686f65f69c7cf122b4ce8174 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Thu, 20 Aug 2026 16:01:23 -0500 Subject: [PATCH 2/7] feat: core commands init, add, list, untrack --- service/project.go | 258 ++++++++++++++++++++++++++++++++++++++ service/project_test.go | 265 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 523 insertions(+) create mode 100644 service/project.go create mode 100644 service/project_test.go diff --git a/service/project.go b/service/project.go new file mode 100644 index 0000000..a91f7a3 --- /dev/null +++ b/service/project.go @@ -0,0 +1,258 @@ +package service + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/polymorcodeus/lnk/internal/gitboundary" + "github.com/polymorcodeus/lnk/internal/lnkerror" + "github.com/polymorcodeus/lnk/internal/patterns" + "github.com/polymorcodeus/lnk/internal/resolver" +) + +// ProjectService implements project-scope operations for the lnk repo +// managed by the embedded Service. +type ProjectService struct { + svc *Service +} + +// NewProjectService creates a ProjectService backed by svc. +func NewProjectService(svc *Service) *ProjectService { + return &ProjectService{svc: svc} +} + +// ProjectInit activates project scope for projectRoot by creating an empty +// ./.lnkinclude file if one does not already exist. It returns true when the +// file was created and false when it already existed. +func (ps *ProjectService) ProjectInit(ctx context.Context, projectRoot string) (bool, error) { + projectRoot, err := filepath.Abs(projectRoot) + if err != nil { + return false, fmt.Errorf("resolve project root: %w", err) + } + + if err := ps.requireInsideGitRepo(ctx, projectRoot); err != nil { + return false, err + } + + if _, err := resolver.ResolveProjectID(ctx, projectRoot); err != nil { + return false, fmt.Errorf("resolve project id: %w", err) + } + + manifest := filepath.Join(projectRoot, ".lnkinclude") + if _, err := os.Stat(manifest); err == nil { + return false, nil + } else if !errors.Is(err, os.ErrNotExist) { + return false, fmt.Errorf("check .lnkinclude: %w", err) + } + + if err := os.WriteFile(manifest, []byte{}, 0o644); err != nil { + return false, fmt.Errorf("create .lnkinclude: %w", err) + } + return true, nil +} + +// ProjectAddPattern appends a pattern to the project's ./.lnkinclude file. +// Absolute paths inside the project are normalized to project-relative form. +// It returns the normalized pattern that was written. +func (ps *ProjectService) ProjectAddPattern(ctx context.Context, projectRoot, rawPattern string) (string, error) { + projectRoot, err := filepath.Abs(projectRoot) + if err != nil { + return "", fmt.Errorf("resolve project root: %w", err) + } + + if err := ps.requireInsideGitRepo(ctx, projectRoot); err != nil { + return "", err + } + + if _, err := resolver.ResolveProjectID(ctx, projectRoot); err != nil { + return "", fmt.Errorf("resolve project id: %w", err) + } + + pattern, err := ps.normalizePattern(projectRoot, rawPattern) + if err != nil { + return "", err + } + + manifest := filepath.Join(projectRoot, ".lnkinclude") + existing, err := patterns.Load(manifest) + if err != nil { + return "", fmt.Errorf("load .lnkinclude: %w", err) + } + if slices.Contains(existing, pattern) { + return "", lnkerror.WithPath(lnkerror.ErrAlreadyManaged, pattern) + } + + if err := ps.appendPattern(manifest, pattern); err != nil { + return "", err + } + + return pattern, nil +} + +// ProjectListPatterns returns the effective patterns for a project, split +// into global (lnk repo root) and local (./.lnkinclude) lists. +func (ps *ProjectService) ProjectListPatterns(ctx context.Context, projectRoot string) (global, local []string, err error) { + projectRoot, err = filepath.Abs(projectRoot) + if err != nil { + return nil, nil, fmt.Errorf("resolve project root: %w", err) + } + + if err := ps.requireInsideGitRepo(ctx, projectRoot); err != nil { + return nil, nil, err + } + + if _, err := resolver.ResolveProjectID(ctx, projectRoot); err != nil { + return nil, nil, fmt.Errorf("resolve project id: %w", err) + } + + global, err = patterns.Load(filepath.Join(ps.svc.RepoPath(), ".lnkinclude")) + if err != nil { + return nil, nil, fmt.Errorf("load global .lnkinclude: %w", err) + } + + local, err = patterns.Load(filepath.Join(projectRoot, ".lnkinclude")) + if err != nil { + return nil, nil, fmt.Errorf("load local .lnkinclude: %w", err) + } + + return global, local, nil +} + +// ProjectUntrackPattern removes a pattern from the project's ./.lnkinclude. +// It returns removed=true when the pattern was removed from the local file. +// When the pattern exists only in the global file, it returns removed=false +// and isGlobal=true so the caller can print a warning. +func (ps *ProjectService) ProjectUntrackPattern(projectRoot, pattern string) (removed, isGlobal bool, err error) { + projectRoot, err = filepath.Abs(projectRoot) + if err != nil { + return false, false, fmt.Errorf("resolve project root: %w", err) + } + + localPath := filepath.Join(projectRoot, ".lnkinclude") + localLines, err := patterns.Load(localPath) + if err != nil { + return false, false, fmt.Errorf("load local .lnkinclude: %w", err) + } + + removed = slices.Contains(localLines, pattern) + + if removed { + if err := ps.rewritePatterns(localPath, localLines, pattern); err != nil { + return false, false, err + } + return true, false, nil + } + + global, err := patterns.Load(filepath.Join(ps.svc.RepoPath(), ".lnkinclude")) + if err != nil { + return false, false, fmt.Errorf("load global .lnkinclude: %w", err) + } + if slices.Contains(global, pattern) { + return false, true, nil + } + + return false, false, lnkerror.WithPath(lnkerror.ErrNotManaged, pattern) +} + +// requireInsideGitRepo returns an error if projectRoot is not inside a git repo. +func (ps *ProjectService) requireInsideGitRepo(ctx context.Context, projectRoot string) error { + inside, _, err := gitboundary.IsInsideGitRepo(ctx, projectRoot) + if err != nil { + return err + } + if !inside { + return lnkerror.WithPathAndSuggestion(lnkerror.ErrOutsideGitRepo, projectRoot, "use 'lnk add' for host/common scope") + } + return nil +} + +// normalizePattern converts an absolute path inside the project to a +// project-relative pattern. Patterns that are already relative or that lie +// outside the project are returned unchanged. +func (ps *ProjectService) normalizePattern(projectRoot, rawPattern string) (string, error) { + if !filepath.IsAbs(rawPattern) { + absPattern, err := filepath.Abs(filepath.Join(projectRoot, rawPattern)) + if err != nil { + return "", fmt.Errorf("resolve pattern %s: %w", rawPattern, err) + } + rel, err := filepath.Rel(projectRoot, absPattern) + if err != nil { + return "", fmt.Errorf("relativize pattern %s: %w", rawPattern, err) + } + if !strings.HasPrefix(rel, "..") && rel != ".." { + return rel, nil + } + return rawPattern, nil + } + + absPattern, err := filepath.Abs(rawPattern) + if err != nil { + return "", fmt.Errorf("resolve pattern %s: %w", rawPattern, err) + } + rel, err := filepath.Rel(projectRoot, absPattern) + if err != nil { + return "", fmt.Errorf("relativize pattern %s: %w", rawPattern, err) + } + if strings.HasPrefix(rel, "..") || rel == ".." { + return "", lnkerror.WithPathAndSuggestion(lnkerror.ErrOutsideGitRepo, rawPattern, "pattern must be inside the project") + } + return rel, nil +} + +// appendPattern appends pattern to manifest, ensuring a preceding newline when +// the file already exists and does not end with one. +func (ps *ProjectService) appendPattern(manifest, pattern string) error { + f, err := os.OpenFile(manifest, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("open .lnkinclude: %w", err) + } + defer func() { + _ = f.Close() + }() + + info, err := os.Stat(manifest) + if err == nil && info.Size() > 0 { + data, err := os.ReadFile(manifest) + if err == nil && len(data) > 0 && data[len(data)-1] != '\n' { + if _, err := f.WriteString("\n"); err != nil { + return fmt.Errorf("write newline: %w", err) + } + } + } + + if _, err := f.WriteString(pattern + "\n"); err != nil { + return fmt.Errorf("write pattern: %w", err) + } + return nil +} + +// rewritePatterns writes lines back to manifest, excluding dropPattern. +func (ps *ProjectService) rewritePatterns(manifest string, lines []string, dropPattern string) error { + f, err := os.Create(manifest) + if err != nil { + return fmt.Errorf("rewrite .lnkinclude: %w", err) + } + defer func() { + _ = f.Close() + }() + + w := bufio.NewWriter(f) + for _, p := range lines { + if p == dropPattern { + continue + } + if _, err := w.WriteString(p + "\n"); err != nil { + return fmt.Errorf("write pattern: %w", err) + } + } + if err := w.Flush(); err != nil { + return fmt.Errorf("flush .lnkinclude: %w", err) + } + return nil +} diff --git a/service/project_test.go b/service/project_test.go new file mode 100644 index 0000000..883e721 --- /dev/null +++ b/service/project_test.go @@ -0,0 +1,265 @@ +package service_test + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/polymorcodeus/lnk/internal/lnkerror" + "github.com/polymorcodeus/lnk/internal/resolver" + "github.com/polymorcodeus/lnk/internal/testhelpers" + "github.com/polymorcodeus/lnk/service" +) + +func initProjectRepo(t *testing.T, dir string) { + t.Helper() + testhelpers.InitGitRepo(t, dir) + if out, err := exec.Command("git", "-C", dir, "remote", "add", "origin", "git@github.com:User/Repo.git").CombinedOutput(); err != nil { + t.Fatalf("git remote add: %v\n%s", err, out) + } + // Create a file and commit so HEAD exists. + 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 TestProjectInit_CreatesLnkInclude(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + created, err := ps.ProjectInit(context.Background(), repoDir) + if err != nil { + t.Fatalf("ProjectInit: %v", err) + } + if !created { + t.Error("expected created=true") + } + + manifest := filepath.Join(repoDir, ".lnkinclude") + if !testhelpers.FileExists(t, manifest) { + t.Error("expected .lnkinclude to be created") + } +} + +func TestProjectInit_Idempotent(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, err := ps.ProjectInit(context.Background(), repoDir); err != nil { + t.Fatalf("ProjectInit: %v", err) + } + + created, err := ps.ProjectInit(context.Background(), repoDir) + if err != nil { + t.Fatalf("ProjectInit second call: %v", err) + } + if created { + t.Error("expected created=false on second call") + } +} + +func TestProjectInit_RequiresGitRepo(t *testing.T) { + svc, home := testhelpers.TestHome(t) + notARepo := filepath.Join(home, "not-a-repo") + testhelpers.MakeDir(t, notARepo) + + ps := service.NewProjectService(svc) + _, err := ps.ProjectInit(context.Background(), notARepo) + if err == nil { + t.Fatal("expected error outside git repo") + } + if !errors.Is(err, lnkerror.ErrOutsideGitRepo) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrOutsideGitRepo) + } +} + +func TestProjectInit_RequiresOrigin(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + testhelpers.InitGitRepo(t, repoDir) + + ps := service.NewProjectService(svc) + _, err := ps.ProjectInit(context.Background(), repoDir) + if err == nil { + t.Fatal("expected error without origin") + } + if !errors.Is(err, resolver.ErrNoOrigin) { + t.Errorf("error = %v, want %v", err, resolver.ErrNoOrigin) + } +} + +func TestProjectAddPattern_AppendsAndNormalizes(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + absPattern := filepath.Join(repoDir, ".cursor", "rules.md") + + normalized, err := ps.ProjectAddPattern(context.Background(), repoDir, absPattern) + if err != nil { + t.Fatalf("ProjectAddPattern: %v", err) + } + if normalized != filepath.Join(".cursor", "rules.md") { + t.Errorf("normalized = %q, want .cursor/rules.md", normalized) + } + + manifest := filepath.Join(repoDir, ".lnkinclude") + content, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + if string(content) != filepath.Join(".cursor", "rules.md")+"\n" { + t.Errorf(".lnkinclude content = %q", string(content)) + } +} + +func TestProjectAddPattern_RejectsOutsideProject(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + outside := filepath.Join(home, ".bashrc") + _, err := ps.ProjectAddPattern(context.Background(), repoDir, outside) + if err == nil { + t.Fatal("expected error for pattern outside project") + } + if !errors.Is(err, lnkerror.ErrOutsideGitRepo) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrOutsideGitRepo) + } +} + +func TestProjectAddPattern_RejectsDuplicate(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + pattern := ".cursor/**" + if _, err := ps.ProjectAddPattern(context.Background(), repoDir, pattern); err != nil { + t.Fatalf("first add: %v", err) + } + + _, err := ps.ProjectAddPattern(context.Background(), repoDir, pattern) + if err == nil { + t.Fatal("expected error for duplicate pattern") + } + if !errors.Is(err, lnkerror.ErrAlreadyManaged) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrAlreadyManaged) + } +} + +func TestProjectListPatterns_SplitsGlobalAndLocal(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + globalPath := filepath.Join(svc.RepoPath(), ".lnkinclude") + if err := os.WriteFile(globalPath, []byte(".todo/**\n"), 0o644); err != nil { + t.Fatal(err) + } + + ps := service.NewProjectService(svc) + if _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { + t.Fatalf("add: %v", err) + } + + global, local, err := ps.ProjectListPatterns(context.Background(), repoDir) + if err != nil { + t.Fatalf("ProjectListPatterns: %v", err) + } + if len(global) != 1 || global[0] != ".todo/**" { + t.Errorf("global = %v, want [.todo/**]", global) + } + if len(local) != 1 || local[0] != ".cursor/**" { + t.Errorf("local = %v, want [.cursor/**]", local) + } +} + +func TestProjectUntrackPattern_RemovesLocal(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, err := ps.ProjectAddPattern(context.Background(), repoDir, "agents.md"); err != nil { + t.Fatalf("add: %v", err) + } + + removed, isGlobal, err := ps.ProjectUntrackPattern(repoDir, "agents.md") + if err != nil { + t.Fatalf("ProjectUntrackPattern: %v", err) + } + if !removed { + t.Error("expected removed=true") + } + if isGlobal { + t.Error("expected isGlobal=false") + } +} + +func TestProjectUntrackPattern_WarnsForGlobal(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + globalPath := filepath.Join(svc.RepoPath(), ".lnkinclude") + if err := os.WriteFile(globalPath, []byte(".todo/**\n"), 0o644); err != nil { + t.Fatal(err) + } + + ps := service.NewProjectService(svc) + removed, isGlobal, err := ps.ProjectUntrackPattern(repoDir, ".todo/**") + if err != nil { + t.Fatalf("ProjectUntrackPattern: %v", err) + } + if removed { + t.Error("expected removed=false") + } + if !isGlobal { + t.Error("expected isGlobal=true") + } +} + +func TestProjectUntrackPattern_ErrorsWhenMissing(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + _, _, err := ps.ProjectUntrackPattern(repoDir, "missing.md") + if err == nil { + t.Fatal("expected error for missing pattern") + } + if !errors.Is(err, lnkerror.ErrNotManaged) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrNotManaged) + } +} From ad73a9f787baa1cc454bfbeb342f3f70e989fecd Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Thu, 20 Aug 2026 18:23:56 -0500 Subject: [PATCH 3/7] feat: core commands push, restore, pull for project scope --- cmd/root.go | 88 +++++++++++ internal/lnkerror/error.go | 1 + service/project.go | 241 ++++++++++++++++++++++++++++++ service/project_test.go | 294 +++++++++++++++++++++++++++++++++++++ 4 files changed, 624 insertions(+) diff --git a/cmd/root.go b/cmd/root.go index cbe7758..978348d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -174,6 +174,9 @@ func newProjectCmd(repoFlag *string) *cobra.Command { cmd.AddCommand(newProjectAddCmd(repoFlag)) cmd.AddCommand(newProjectListCmd(repoFlag)) cmd.AddCommand(newProjectUntrackCmd(repoFlag)) + cmd.AddCommand(newProjectPushCmd(repoFlag)) + cmd.AddCommand(newProjectRestoreCmd(repoFlag)) + cmd.AddCommand(newProjectPullCmd(repoFlag)) return cmd } @@ -346,6 +349,91 @@ func newProjectUntrackCmd(repoFlag *string) *cobra.Command { return cmd } +// newProjectPushCmd returns the "project push" subcommand. +func newProjectPushCmd(repoFlag *string) *cobra.Command { + return &cobra.Command{ + Use: "push", + Short: "Push matching project files into lnk storage", + RunE: func(cmd *cobra.Command, args []string) error { + projectRoot, err := projectDir(cmd) + if err != nil { + return err + } + + ps := service.NewProjectService(svc(repoFlag)) + result, err := ps.ProjectPush(cmd.Context(), projectRoot) + if err != nil { + return err + } + + if len(result.Synced) == 0 { + _, err = fmt.Fprintln(cmd.OutOrStdout(), "Nothing to sync — all tracked files are already up to date") + return err + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Synced %d file(s) to project storage\n", len(result.Synced)); err != nil { + return err + } + for _, path := range result.Synced { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), " %s\n", path); err != nil { + return err + } + } + return nil + }, + } +} + +// newProjectRestoreCmd returns the "project restore" subcommand. +func newProjectRestoreCmd(repoFlag *string) *cobra.Command { + var dryRun bool + + cmd := &cobra.Command{ + Use: "restore [--dry-run]", + Short: "Recreate symlinks for project files from storage", + RunE: func(cmd *cobra.Command, args []string) error { + projectRoot, err := projectDir(cmd) + if err != nil { + return err + } + + ps := service.NewProjectService(svc(repoFlag)) + info, err := ps.ProjectRestore(cmd.Context(), projectRoot, dryRun) + if err != nil { + return err + } + return printRestore(cmd.OutOrStdout(), info, dryRun) + }, + } + + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview restore actions without changing files") + return cmd +} + +// newProjectPullCmd returns the "project pull" subcommand. +func newProjectPullCmd(repoFlag *string) *cobra.Command { + return &cobra.Command{ + Use: "pull", + Short: "Pull lnk repo changes and restore project symlinks", + RunE: func(cmd *cobra.Command, args []string) error { + projectRoot, err := projectDir(cmd) + if err != nil { + return err + } + + ps := service.NewProjectService(svc(repoFlag)) + info, err := ps.ProjectPull(cmd.Context(), projectRoot) + if err != nil { + return err + } + if err := printRestore(cmd.OutOrStdout(), info, false); err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), "Pulled project changes") + return err + }, + } +} + // newMoveCmd returns the "move" subcommand. func newMoveCmd(repoFlag *string) *cobra.Command { var toCommon bool diff --git a/internal/lnkerror/error.go b/internal/lnkerror/error.go index 881a2ae..cb64b28 100644 --- a/internal/lnkerror/error.go +++ b/internal/lnkerror/error.go @@ -22,6 +22,7 @@ var ( ErrInsideGitRepo = errors.New("file is inside a git repo") ErrOutsideGitRepo = errors.New("file is outside any git repo") ErrProjectScopeNotImplemented = errors.New("project scope is not yet implemented") + ErrNoPatterns = errors.New("no patterns defined") ) // Error wraps a sentinel error with optional context for display. diff --git a/service/project.go b/service/project.go index a91f7a3..2160b12 100644 --- a/service/project.go +++ b/service/project.go @@ -10,10 +10,12 @@ import ( "slices" "strings" + fspkg "github.com/polymorcodeus/lnk/internal/fs" "github.com/polymorcodeus/lnk/internal/gitboundary" "github.com/polymorcodeus/lnk/internal/lnkerror" "github.com/polymorcodeus/lnk/internal/patterns" "github.com/polymorcodeus/lnk/internal/resolver" + "github.com/polymorcodeus/lnk/internal/scope" ) // ProjectService implements project-scope operations for the lnk repo @@ -256,3 +258,242 @@ func (ps *ProjectService) rewritePatterns(manifest string, lines []string, dropP } return nil } + +// ProjectPushResult reports the files moved to storage and symlinked back by +// ProjectPush. +type ProjectPushResult struct { + ProjectID string + Synced []string +} + +// ProjectPush walks the project repository, moves matching files to the lnk +// project storage directory, and symlinks them back. It then stages and +// commits the changes in the lnk repo. +func (ps *ProjectService) ProjectPush(ctx context.Context, projectRoot string) (ProjectPushResult, error) { + projectRoot, err := filepath.Abs(projectRoot) + if err != nil { + return ProjectPushResult{}, fmt.Errorf("resolve project root: %w", err) + } + + if err := ps.requireInsideGitRepo(ctx, projectRoot); err != nil { + return ProjectPushResult{}, err + } + + id, err := resolver.ResolveProjectID(ctx, projectRoot) + if err != nil { + return ProjectPushResult{}, fmt.Errorf("resolve project id: %w", err) + } + + storageDir := filepath.Join(ps.svc.RepoPath(), "projects", id) + if err := os.MkdirAll(storageDir, 0o755); err != nil { + return ProjectPushResult{}, fmt.Errorf("create project storage: %w", err) + } + + resolver := &scope.ProjectRootResolver{ + GitRoot: projectRoot, + StorageDir: storageDir, + } + + global, err := patterns.Load(filepath.Join(ps.svc.RepoPath(), ".lnkinclude")) + if err != nil { + return ProjectPushResult{}, fmt.Errorf("load global .lnkinclude: %w", err) + } + + local, err := patterns.Load(filepath.Join(projectRoot, ".lnkinclude")) + if err != nil { + return ProjectPushResult{}, fmt.Errorf("load local .lnkinclude: %w", err) + } + + effective := append(global, local...) + if len(effective) == 0 { + return ProjectPushResult{}, lnkerror.Wrap(lnkerror.ErrNoPatterns) + } + + result := ProjectPushResult{ProjectID: id} + fs := &fspkg.FileSystem{} + + err = filepath.Walk(projectRoot, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return nil + } + if info.IsDir() { + if info.Name() == ".git" { + return filepath.SkipDir + } + return nil + } + if info.Mode()&os.ModeSymlink != 0 { + return nil + } + + rel, err := resolver.ToStorage(path) + if err != nil { + return nil + } + rel = filepath.ToSlash(rel) + + match, err := patterns.Match(effective, rel) + if err != nil || !match { + return nil + } + + storagePath := filepath.Join(storageDir, rel) + + liveInfo, err := os.Lstat(path) + if err != nil { + return nil + } + + if liveInfo.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(path) + if err == nil { + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(path), target) + } + target = filepath.Clean(target) + if target == storagePath { + if _, err := os.Stat(storagePath); err == nil { + return nil + } + } + } + if err := os.Remove(path); err != nil { + return nil + } + } + + if err := os.MkdirAll(filepath.Dir(storagePath), 0o755); err != nil { + return nil + } + + if moveErr := fs.MoveFile(path, storagePath); moveErr != nil { + if _, statErr := os.Stat(storagePath); statErr == nil { + if err := fs.CreateSymlink(storagePath, path); err != nil { + return nil + } + result.Synced = append(result.Synced, rel) + } + return nil + } + + if err := fs.CreateSymlink(storagePath, path); err != nil { + _ = fs.MoveFile(storagePath, path) + return nil + } + + result.Synced = append(result.Synced, rel) + return nil + }) + if err != nil { + return result, err + } + + if err := ps.svc.git.AddAll(ctx); err != nil { + return result, err + } + + hasChanges, err := ps.svc.git.HasChanges(ctx) + if err != nil { + return result, err + } + if !hasChanges { + return result, nil + } + + if err := ps.svc.commit(ctx, "lnk: sync project "+id); err != nil { + return result, err + } + + return result, nil +} + +// ProjectRestore recreates symlinks for all project-scoped files from storage. +func (ps *ProjectService) ProjectRestore(ctx context.Context, projectRoot string, dryRun bool) (RestoreInfo, error) { + projectRoot, err := filepath.Abs(projectRoot) + if err != nil { + return RestoreInfo{}, fmt.Errorf("resolve project root: %w", err) + } + + id, err := resolver.ResolveProjectID(ctx, projectRoot) + if err != nil { + return RestoreInfo{}, fmt.Errorf("resolve project id: %w", err) + } + + storageDir := filepath.Join(ps.svc.RepoPath(), "projects", id) + if _, err := os.Stat(storageDir); os.IsNotExist(err) { + return RestoreInfo{}, lnkerror.WithPathAndSuggestion(lnkerror.ErrNotManaged, projectRoot, "run 'lnk project push' first") + } + + resolver := &scope.ProjectRootResolver{ + GitRoot: projectRoot, + StorageDir: storageDir, + } + + info := RestoreInfo{} + fs := &fspkg.FileSystem{} + + err = filepath.Walk(storageDir, func(path string, fi os.FileInfo, walkErr error) error { + if walkErr != nil || fi.IsDir() { + return nil + } + + rel, err := filepath.Rel(storageDir, path) + if err != nil { + return nil + } + rel = filepath.ToSlash(rel) + + livePath, err := resolver.ToLive(rel) + if err != nil { + return nil + } + + liveInfo, err := os.Lstat(livePath) + if err == nil { + if liveInfo.Mode()&os.ModeSymlink != 0 { + if !dryRun { + if err := os.Remove(livePath); err != nil { + return nil + } + } + } else { + info.BackedUp = append(info.BackedUp, rel) + if !dryRun { + backupPath := livePath + ".lnk-backup" + if _, err := os.Lstat(backupPath); err == nil { + return lnkerror.WithPath(lnkerror.ErrBackupExists, backupPath) + } + if err := os.Rename(livePath, backupPath); err != nil { + return fmt.Errorf("backup existing file %s: %w", livePath, err) + } + } + } + } + + info.Restored = append(info.Restored, rel) + if dryRun { + return nil + } + + if err := os.MkdirAll(filepath.Dir(livePath), 0o755); err != nil { + return fmt.Errorf("create live parent directory: %w", err) + } + if err := fs.CreateSymlink(path, livePath); err != nil { + return err + } + return nil + }) + if err != nil { + return info, err + } + + return info, nil +} + +// ProjectPull pulls the lnk repo and restores project symlinks. +func (ps *ProjectService) ProjectPull(ctx context.Context, projectRoot string) (RestoreInfo, error) { + if err := ps.svc.git.Pull(ctx); err != nil { + return RestoreInfo{}, err + } + return ps.ProjectRestore(ctx, projectRoot, false) +} diff --git a/service/project_test.go b/service/project_test.go index 883e721..19044b4 100644 --- a/service/project_test.go +++ b/service/project_test.go @@ -263,3 +263,297 @@ func TestProjectUntrackPattern_ErrorsWhenMissing(t *testing.T) { t.Errorf("error = %v, want %v", err, lnkerror.ErrNotManaged) } } + +func TestProjectPush_MovesMatchingFileToStorage(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + + liveFile := filepath.Join(repoDir, ".cursor", "rules.md") + testhelpers.MakeFile(t, liveFile, "# rules\n") + + result, err := ps.ProjectPush(context.Background(), repoDir) + if err != nil { + t.Fatalf("ProjectPush: %v", err) + } + if len(result.Synced) != 1 || result.Synced[0] != ".cursor/rules.md" { + t.Errorf("synced = %v, want [.cursor/rules.md]", result.Synced) + } + + storageFile := filepath.Join(svc.RepoPath(), "projects", result.ProjectID, ".cursor", "rules.md") + if !testhelpers.FileExists(t, storageFile) { + t.Errorf("expected storage file %s", storageFile) + } + testhelpers.AssertSymlink(t, liveFile, storageFile) + + if logs := testhelpers.GitLog(t, svc.RepoPath()); len(logs) < 2 { + t.Errorf("expected at least 2 commits, got %d", len(logs)) + } +} + +func TestProjectPush_SkipsAlreadySymlinked(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + + liveFile := filepath.Join(repoDir, ".cursor", "rules.md") + testhelpers.MakeFile(t, liveFile, "# rules\n") + + if _, err := ps.ProjectPush(context.Background(), repoDir); err != nil { + t.Fatalf("first push: %v", err) + } + + result, err := ps.ProjectPush(context.Background(), repoDir) + if err != nil { + t.Fatalf("second push: %v", err) + } + if len(result.Synced) != 0 { + t.Errorf("synced = %v, want empty", result.Synced) + } +} + +func TestProjectPush_ErrorsWhenNoPatterns(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + _, err := ps.ProjectPush(context.Background(), repoDir) + if err == nil { + t.Fatal("expected error when no patterns") + } + if !errors.Is(err, lnkerror.ErrNoPatterns) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrNoPatterns) + } +} + +func TestProjectPush_SkipsGitDirectory(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, err := ps.ProjectAddPattern(context.Background(), repoDir, "*.md"); err != nil { + t.Fatalf("add pattern: %v", err) + } + + gitConfig := filepath.Join(repoDir, ".git", "config") + if _, err := os.Stat(gitConfig); err != nil { + t.Fatalf("expected .git/config to exist: %v", err) + } + + liveFile := filepath.Join(repoDir, "README.md") + testhelpers.MakeFile(t, liveFile, "# hello\n") + + result, err := ps.ProjectPush(context.Background(), repoDir) + if err != nil { + t.Fatalf("ProjectPush: %v", err) + } + if len(result.Synced) != 1 || result.Synced[0] != "README.md" { + t.Errorf("synced = %v, want [README.md]", result.Synced) + } + + if !testhelpers.FileExists(t, gitConfig) { + t.Error("expected .git/config to remain in place") + } +} + +func TestProjectPush_CreatesStorageDir(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, err := ps.ProjectAddPattern(context.Background(), repoDir, "*.md"); err != nil { + t.Fatalf("add pattern: %v", err) + } + + id, err := resolver.ResolveProjectID(context.Background(), repoDir) + if err != nil { + t.Fatalf("resolve project id: %v", err) + } + storageDir := filepath.Join(svc.RepoPath(), "projects", id) + if testhelpers.FileExists(t, storageDir) { + t.Fatal("expected storage dir not to exist before push") + } + + liveFile := filepath.Join(repoDir, "notes.md") + testhelpers.MakeFile(t, liveFile, "notes\n") + + if _, err := ps.ProjectPush(context.Background(), repoDir); err != nil { + t.Fatalf("ProjectPush: %v", err) + } + + if !testhelpers.FileExists(t, storageDir) { + t.Error("expected storage dir to be created") + } +} + +func TestProjectRestore_RecreatesSymlinks(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + + liveFile := filepath.Join(repoDir, ".cursor", "rules.md") + testhelpers.MakeFile(t, liveFile, "# rules\n") + + result, err := ps.ProjectPush(context.Background(), repoDir) + if err != nil { + t.Fatalf("ProjectPush: %v", err) + } + + storageFile := filepath.Join(svc.RepoPath(), "projects", result.ProjectID, ".cursor", "rules.md") + if err := os.Remove(liveFile); err != nil { + t.Fatalf("remove symlink: %v", err) + } + + info, err := ps.ProjectRestore(context.Background(), repoDir, false) + if err != nil { + t.Fatalf("ProjectRestore: %v", err) + } + if len(info.Restored) != 1 { + t.Errorf("restored = %v, want 1 entry", info.Restored) + } + testhelpers.AssertSymlink(t, liveFile, storageFile) +} + +func TestProjectRestore_BackupsExistingFile(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + + liveFile := filepath.Join(repoDir, ".cursor", "rules.md") + testhelpers.MakeFile(t, liveFile, "# rules\n") + + result, err := ps.ProjectPush(context.Background(), repoDir) + if err != nil { + t.Fatalf("ProjectPush: %v", err) + } + + storageFile := filepath.Join(svc.RepoPath(), "projects", result.ProjectID, ".cursor", "rules.md") + if err := os.Remove(liveFile); err != nil { + t.Fatalf("remove symlink: %v", err) + } + testhelpers.MakeFile(t, liveFile, "local changes\n") + + info, err := ps.ProjectRestore(context.Background(), repoDir, false) + if err != nil { + t.Fatalf("ProjectRestore: %v", err) + } + if len(info.Restored) != 1 || len(info.BackedUp) != 1 { + t.Errorf("restored = %v, backed up = %v", info.Restored, info.BackedUp) + } + + testhelpers.AssertSymlink(t, liveFile, storageFile) + backupFile := liveFile + ".lnk-backup" + if !testhelpers.FileExists(t, backupFile) { + t.Error("expected .lnk-backup file") + } +} + +func TestProjectRestore_DryRun(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + + liveFile := filepath.Join(repoDir, ".cursor", "rules.md") + testhelpers.MakeFile(t, liveFile, "# rules\n") + + if _, err := ps.ProjectPush(context.Background(), repoDir); err != nil { + t.Fatalf("ProjectPush: %v", err) + } + + if err := os.Remove(liveFile); err != nil { + t.Fatalf("remove symlink: %v", err) + } + + info, err := ps.ProjectRestore(context.Background(), repoDir, true) + if err != nil { + t.Fatalf("ProjectRestore: %v", err) + } + if len(info.Restored) != 1 { + t.Errorf("restored = %v, want 1 entry", info.Restored) + } + + if _, err := os.Lstat(liveFile); err == nil { + t.Error("expected symlink not to be created in dry-run mode") + } +} + +func TestProjectPull_PullsAndRestores(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + remote := testhelpers.NewBareRemote(t) + cmds := [][]string{ + {"git", "-C", svc.RepoPath(), "remote", "add", "origin", remote}, + {"git", "-C", svc.RepoPath(), "push", "-u", "origin", "main"}, + } + 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) + } + } + + ps := service.NewProjectService(svc) + if _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + + liveFile := filepath.Join(repoDir, ".cursor", "rules.md") + testhelpers.MakeFile(t, liveFile, "# rules\n") + + result, err := ps.ProjectPush(context.Background(), repoDir) + if err != nil { + t.Fatalf("ProjectPush: %v", err) + } + + storageFile := filepath.Join(svc.RepoPath(), "projects", result.ProjectID, ".cursor", "rules.md") + if err := os.Remove(liveFile); err != nil { + t.Fatalf("remove symlink: %v", err) + } + + info, err := ps.ProjectPull(context.Background(), repoDir) + if err != nil { + t.Fatalf("ProjectPull: %v", err) + } + if len(info.Restored) != 1 { + t.Errorf("restored = %v, want 1 entry", info.Restored) + } + testhelpers.AssertSymlink(t, liveFile, storageFile) +} From 147d4b94bb3b4977c2f90119ecdd68c209f6a49b Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Fri, 21 Aug 2026 10:35:49 -0500 Subject: [PATCH 4/7] doc: Updated README TESTING with new project scope --- .gitignore | 1 + .goreleaser.yml | 3 +++ Makefile | 10 ++++++++- README.md | 44 +++++++++++++++++++++++++++++++++++---- TESTING.md | 16 +++++++++++--- go.mod | 3 +++ go.sum | 3 +++ tools/gen-docs/main.go | 47 ++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 119 insertions(+), 8 deletions(-) create mode 100644 tools/gen-docs/main.go diff --git a/.gitignore b/.gitignore index 897ba3f..0ee76ec 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ go.work.sum # Build artifacts dist/ bin/ +man/ # IDE and editor files .vscode/ diff --git a/.goreleaser.yml b/.goreleaser.yml index ab0524a..452c49e 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -9,6 +9,8 @@ before: - go mod tidy # you may remove this if you don't need go generate - go generate ./... + # Generate man pages so they ship with releases. + - go run ./tools/gen-docs man builds: - env: @@ -44,6 +46,7 @@ archives: files: - README.md - LICENSE + - man/ builds_info: group: root owner: root diff --git a/Makefile b/Makefile index 2a389a0..f20061e 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ YELLOW=\033[0;33m BLUE=\033[0;34m NC=\033[0m # No Color -.PHONY: help build test test-integration clean install uninstall fmt lint vet tidy run dev cross-compile release goreleaser-check goreleaser-snapshot +.PHONY: help build test test-integration clean install uninstall fmt lint vet tidy run dev man cross-compile release goreleaser-check goreleaser-snapshot ## help: Show this help message help: @@ -30,6 +30,7 @@ help: @echo " test-integration Run integration tests" @echo " run Run the application" @echo " dev Development mode with file watching" + @echo " man Generate man pages" @echo "" @echo "$(GREEN)Code Quality:$(NC)" @echo " fmt Format Go code" @@ -94,6 +95,12 @@ dev: @echo "$(YELLOW)Install 'entr' if not available: brew install entr$(NC)" @find . -name "*.go" | entr -r make run +## man: Generate man pages +man: + @echo "$(BLUE)Generating man pages...$(NC)" + @go run ./tools/gen-docs man + @echo "$(GREEN)Man pages generated$(NC)" + ## fmt: Format Go code fmt: @echo "$(BLUE)Formatting code...$(NC)" @@ -160,6 +167,7 @@ clean: @echo "$(BLUE)Cleaning...$(NC)" @rm -f $(BINARY_NAME) @rm -rf dist/ + @rm -rf man/ @rm -f coverage.out coverage.html @echo "$(GREEN)Clean complete$(NC)" diff --git a/README.md b/README.md index 02c60f8..d2c514c 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,36 @@ lnk clone --bootstrap # runs bootstrap.sh after clone lnk bootstrap # run manually ``` +### Project scope + +Track project-local configuration files without committing them to the project's own git repository. Useful for `.crush/crush.json`, `.vscode/settings.json`, repo-specific shell aliases, or any file you want backed up in your dotfiles repo but not pushed upstream. + +Project scope uses a `.lnkinclude` file inside the project root. Patterns follow `.gitignore` syntax, but a match means "include". Global patterns can live in your lnk repo root; local patterns are project-specific. + +```bash +# inside a git repository +lnk project init # create an empty .lnkinclude +lnk project add .crush/** # track all files under .crush/ +lnk project add .vscode/settings.json # track a single file +lnk project list # show effective global + local patterns +lnk project push # move matches to lnk storage and symlink back +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 +lnk project untrack .crush/** # remove a local 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. + +## Man pages + +Man pages are generated from the Cobra command tree and ship with release archives. + +```bash +make man # generate pages in man/ +man man/lnk-project-push.1 # read a generated page +``` + ## Commands | Command | What it does | @@ -204,6 +234,13 @@ lnk bootstrap # run manually | `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health | | `format [--v1 \| --v2]` | Migrate repo format | | `bootstrap` | Run bootstrap.sh explicitly | +| `project init` | Activate project scope in the current git repo | +| `project add ` | Add patterns to the project's `.lnkinclude` | +| `project list` | Show effective project patterns | +| `project untrack ` | Remove a pattern from the project's `.lnkinclude` | +| `project push` | Move matching project files to lnk storage | +| `project restore [--dry-run]` | Recreate project symlinks from storage | +| `project pull` | Pull lnk repo and restore project symlinks | ## Global Options @@ -215,10 +252,9 @@ Available with all commands: ## Acknowledgements -This originally started off as a fork of [yarlson/lnk](https://github.com/yarlson/lnk) with a number of features that I wanted. -It has since turned into a standalone version after I saw the plan to rewrite a v2 in Rust. I've cleaned up the legacy code and -added some opinionated fixes along the way. This should™ be fully compatible with the original repos from yarlson's tool, -but now stands alone. I can't guarantee backwards or cross compatibility going forward so use both at your own peril. +This originally started off as a fork of [yarlson/lnk](https://github.com/yarlson/lnk) with a number of features that I wanted. It has since turned into a standalone version after I saw the plan to rewrite a v2 in Rust. I've cleaned up the legacy code and added some opinionated fixes along the way. This should™ be fully compatible with the original repos from yarlson's tool, but now stands alone. I can't guarantee backwards or cross compatibility going forward so use both at your own peril. + +The idea of a project scope was born out of seeing [claytercek/offstage](https://github.com/claytercek/offstage). It felt like a good extension of what was already built out here, but I wanted to streamline it and have it fit with the intent I've curated here, namely a targeted working snapshot of my different machine profiles. ## Contributing diff --git a/TESTING.md b/TESTING.md index 557a1e7..c7605c2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -40,29 +40,39 @@ These tests simulate full user workflows: init, add, restore, update, doctor, an ## Scope Test Matrix -`lnk` distinguishes between the `common` scope and per-machine host scopes. When adding tests for any command that accepts a `--host` flag, exercise both dimensions: +`lnk` distinguishes between the `common` scope, per-machine host scopes, and project scopes. When adding tests for any command that accepts a `--host` flag, exercise both dimensions. When adding tests for project scope commands, set up a git repository with an `origin` remote. | Scenario | Host argument | Storage directory | Tracker file | | --- | --- | --- | --- | | Common scope (default) | `""` or `"common"` | `common.lnk/` (v2) or repo root (v1) | `.lnk.common` (v2) or `.lnk` (v1) | | Host scope | `"work"`, `"laptop"`, etc. | `.lnk/` | `.lnk.` | +| Project scope | N/A (uses `--dir`) | `projects//` | N/A (uses `.lnkinclude` patterns) | Use the helpers below to set up each scope consistently: - `testhelpers.TestHome(t)` - temp `$HOME` with a fresh v2 repo - `testhelpers.TestHomeV1(t)` - v2 repo marker but v1 storage layout - `testhelpers.TestHomeV1Legacy(t)` - v1 repo without a `.lnkrepo` marker +- `testhelpers.InitGitRepo(t, dir)` - initialize a git repo with test config +- `testhelpers.NewBareRemote(t)` - create a bare repo for push/pull tests - `setupTrackedFile(t, repoPath, home, scope, relativePath, content)` - creates storage, symlink, and tracker entry for a scope +For project scope tests, also use `resolver.ResolveProjectID(ctx, projectRoot)` to compute the expected storage directory under `projects/`. + ## Key Edge Cases When adding coverage, consider these regression-sensitive scenarios: - **Symlink already exists**: `lnk add` rejects symlinks because it cannot manage them. -- **Backup collision**: `lnk restore` and `lnk doctor --fix` refuse to overwrite an existing `.lnk-backup` file. -- **Dry-run behavior**: `lnk restore --dry-run` reports what would happen without creating symlinks, backups, or removing files. +- **Backup collision**: `lnk restore`, `lnk doctor --fix`, and `lnk project restore` refuse to overwrite an existing `.lnk-backup` file. +- **Dry-run behavior**: `lnk restore --dry-run` and `lnk project restore --dry-run` report what would happen without creating symlinks, backups, or removing files. - **Dirty tree**: `lnk doctor --fix` refuses to run when the working tree has uncommitted changes. - **Uninitialized repo**: commands that require a repo return `ErrNotInitialized`. +- **Project scope requires git repo**: `lnk project` commands fail with `ErrOutsideGitRepo` when run outside a git repository. +- **Project scope requires origin**: `lnk project` commands fail with `resolver.ErrNoOrigin` when the project git repo has no `origin` remote. +- **No project patterns**: `lnk project push` returns `ErrNoPatterns` when no global or local `.lnkinclude` patterns exist. +- **Project `.git` skipped**: `lnk project push` skips any `.git` directory while walking the project tree. +- **Already symlinked project files**: `lnk project push` skips files that already point to the correct storage path. ## Coverage diff --git a/go.mod b/go.mod index 6ed12ee..8349bdd 100644 --- a/go.mod +++ b/go.mod @@ -8,11 +8,14 @@ require ( ) require ( + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.56.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index c991557..f80b46f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= @@ -17,12 +18,14 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= diff --git a/tools/gen-docs/main.go b/tools/gen-docs/main.go new file mode 100644 index 0000000..dc73389 --- /dev/null +++ b/tools/gen-docs/main.go @@ -0,0 +1,47 @@ +// Command gen-docs generates lnk man pages from the Cobra command tree. +package main + +import ( + "fmt" + "log" + "os" + "strings" + + "github.com/spf13/cobra/doc" + + "github.com/polymorcodeus/lnk/cmd" +) + +func main() { + outDir := "man" + if len(os.Args) > 1 { + outDir = os.Args[1] + } + + if err := os.MkdirAll(outDir, 0o755); err != nil { + log.Fatalf("create output directory: %v", err) + } + + // Match the local-build version behavior of the main binary so the + // generated man pages reflect the current source tree. + version := "dev" + if data, err := os.ReadFile("VERSION"); err == nil { + version = strings.TrimSpace(string(data)) + } + cmd.SetVersion(version, "") + + root := cmd.NewRootCommand() + root.DisableAutoGenTag = true + root.CompletionOptions.DisableDefaultCmd = true + + header := &doc.GenManHeader{ + Title: "LNK", + Section: "1", + } + + if err := doc.GenManTree(root, header, outDir); err != nil { + log.Fatalf("generate man pages: %v", err) + } + + fmt.Printf("man pages written to %s/\n", outDir) +} From b9931851371b2459cde2706e4ec20fd278531613 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sun, 23 Aug 2026 11:43:14 -0500 Subject: [PATCH 5/7] feat: adding guardrails for project scope, lnk add inside git repo, and pattern validations --- VERSION | 2 +- cmd/root.go | 74 ++-- internal/git/git.go | 6 + internal/git/git_test.go | 28 ++ internal/lnkerror/error.go | 4 + internal/resolver/resolver.go | 17 + internal/resolver/resolver_test.go | 21 ++ service/add.go | 10 + service/add_test.go | 18 + service/project.go | 497 ++++++++++++++++---------- service/project_test.go | 538 ++++++++++++++++++++++++++--- service/service.go | 3 + 12 files changed, 956 insertions(+), 262 deletions(-) diff --git a/VERSION b/VERSION index 2e7bd91..46b105a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.5.0 +v2.0.0 diff --git a/cmd/root.go b/cmd/root.go index 978348d..e18e992 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,7 +10,6 @@ import ( "github.com/spf13/cobra" - "github.com/polymorcodeus/lnk/internal/gitboundary" "github.com/polymorcodeus/lnk/service" ) @@ -181,7 +180,7 @@ func newProjectCmd(repoFlag *string) *cobra.Command { } // projectDir resolves the --dir flag (defaulting to the current working -// directory) and validates that it is inside a git repository. +// directory). The service layer anchors it at the enclosing git repo root. func projectDir(cmd *cobra.Command) (string, error) { dir, _ := cmd.Flags().GetString("dir") if dir == "" { @@ -197,14 +196,6 @@ func projectDir(cmd *cobra.Command) (string, error) { return "", fmt.Errorf("resolve path %s: %w", dir, err) } - inside, _, err := gitboundary.IsInsideGitRepo(cmd.Context(), absDir) - if err != nil { - return "", err - } - if !inside { - return "", fmt.Errorf("%s is not inside a git repository", absDir) - } - return absDir, nil } @@ -249,13 +240,18 @@ func newProjectAddCmd(repoFlag *string) *cobra.Command { ps := service.NewProjectService(svc(repoFlag)) for _, pattern := range args { - normalized, err := ps.ProjectAddPattern(cmd.Context(), projectRoot, pattern) + normalized, matched, err := ps.ProjectAddPattern(cmd.Context(), projectRoot, pattern) if err != nil { return err } if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Added '%s' to .lnkinclude — remember to commit it.\n", normalized); err != nil { return err } + if !matched { + if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "warning: '%s' matches no existing files; it will apply to future matches\n", normalized); err != nil { + return err + } + } } return nil }, @@ -331,7 +327,7 @@ func newProjectUntrackCmd(repoFlag *string) *cobra.Command { } ps := service.NewProjectService(svc(repoFlag)) - removed, isGlobal, err := ps.ProjectUntrackPattern(projectRoot, args[0]) + removed, isGlobal, err := ps.ProjectUntrackPattern(cmd.Context(), projectRoot, args[0]) if err != nil { return err } @@ -351,8 +347,10 @@ func newProjectUntrackCmd(repoFlag *string) *cobra.Command { // newProjectPushCmd returns the "project push" subcommand. func newProjectPushCmd(repoFlag *string) *cobra.Command { - return &cobra.Command{ - Use: "push", + var force bool + + cmd := &cobra.Command{ + Use: "push [--force]", Short: "Push matching project files into lnk storage", RunE: func(cmd *cobra.Command, args []string) error { projectRoot, err := projectDir(cmd) @@ -361,13 +359,21 @@ func newProjectPushCmd(repoFlag *string) *cobra.Command { } ps := service.NewProjectService(svc(repoFlag)) - result, err := ps.ProjectPush(cmd.Context(), projectRoot) + result, err := ps.ProjectPush(cmd.Context(), projectRoot, force) if 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 { + return err + } + } + if len(result.Synced) == 0 { - _, err = fmt.Fprintln(cmd.OutOrStdout(), "Nothing to sync — all tracked files are already up to date") + if len(result.SkippedTracked) == 0 { + _, err = fmt.Fprintln(cmd.OutOrStdout(), "Nothing to sync — all tracked files are already up to date") + } return err } if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Synced %d file(s) to project storage\n", len(result.Synced)); err != nil { @@ -381,14 +387,18 @@ func newProjectPushCmd(repoFlag *string) *cobra.Command { return nil }, } + + cmd.Flags().BoolVar(&force, "force", false, "also manage files tracked by the project's own git") + return cmd } // newProjectRestoreCmd returns the "project restore" subcommand. func newProjectRestoreCmd(repoFlag *string) *cobra.Command { var dryRun bool + var force bool cmd := &cobra.Command{ - Use: "restore [--dry-run]", + Use: "restore [--dry-run] [--force]", Short: "Recreate symlinks for project files from storage", RunE: func(cmd *cobra.Command, args []string) error { projectRoot, err := projectDir(cmd) @@ -397,7 +407,7 @@ func newProjectRestoreCmd(repoFlag *string) *cobra.Command { } ps := service.NewProjectService(svc(repoFlag)) - info, err := ps.ProjectRestore(cmd.Context(), projectRoot, dryRun) + info, err := ps.ProjectRestore(cmd.Context(), projectRoot, dryRun, force) if err != nil { return err } @@ -406,13 +416,16 @@ func newProjectRestoreCmd(repoFlag *string) *cobra.Command { } cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview restore actions without changing files") + cmd.Flags().BoolVar(&force, "force", false, "replace files tracked by the project's own git") return cmd } // newProjectPullCmd returns the "project pull" subcommand. func newProjectPullCmd(repoFlag *string) *cobra.Command { - return &cobra.Command{ - Use: "pull", + var force bool + + cmd := &cobra.Command{ + Use: "pull [--force]", Short: "Pull lnk repo changes and restore project symlinks", RunE: func(cmd *cobra.Command, args []string) error { projectRoot, err := projectDir(cmd) @@ -421,7 +434,7 @@ func newProjectPullCmd(repoFlag *string) *cobra.Command { } ps := service.NewProjectService(svc(repoFlag)) - info, err := ps.ProjectPull(cmd.Context(), projectRoot) + info, err := ps.ProjectPull(cmd.Context(), projectRoot, force) if err != nil { return err } @@ -432,6 +445,9 @@ func newProjectPullCmd(repoFlag *string) *cobra.Command { return err }, } + + cmd.Flags().BoolVar(&force, "force", false, "replace files tracked by the project's own git") + return cmd } // newMoveCmd returns the "move" subcommand. @@ -806,13 +822,23 @@ func printRestore(w io.Writer, info service.RestoreInfo, dryRun bool) error { return err } } - if len(info.BackedUp) == 0 { + if len(info.BackedUp) > 0 { + if _, err := fmt.Fprintf(w, "%s %d conflicting path(s)\n", backupPrefix, len(info.BackedUp)); err != nil { + return err + } + for _, path := range info.BackedUp { + if _, err := fmt.Fprintf(w, " %s\n", path); err != nil { + return err + } + } + } + if len(info.SkippedTracked) == 0 { return nil } - if _, err := fmt.Fprintf(w, "%s %d conflicting path(s)\n", backupPrefix, len(info.BackedUp)); err != nil { + if _, err := fmt.Fprintf(w, "Skipped %d path(s) tracked by the project's git (use --force to manage them)\n", len(info.SkippedTracked)); err != nil { return err } - for _, path := range info.BackedUp { + for _, path := range info.SkippedTracked { if _, err := fmt.Fprintf(w, " %s\n", path); err != nil { return err } diff --git a/internal/git/git.go b/internal/git/git.go index 17eedd6..7f9ceae 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -78,6 +78,12 @@ func (g *Git) runGitCommand(ctx context.Context, timeout time.Duration, args ... return cmd.CombinedOutput() } +// Run executes an arbitrary read-only git command against the repository and +// returns its combined output. +func (g *Git) Run(ctx context.Context, args ...string) ([]byte, error) { + return g.runGitCommand(ctx, shortTimeout, args...) +} + // Init initializes a new Git repository func (g *Git) Init(ctx context.Context) error { // Try using git init -b main first (Git 2.28+) diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 7a239a4..fc87030 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -56,6 +56,34 @@ func TestGit_Init(t *testing.T) { }) } +func TestGit_Run(t *testing.T) { + t.Parallel() + + t.Run("executes_read_only_command", func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + g := initRepo(t, tmp) + + out, err := g.Run(context.Background(), "rev-parse", "--is-inside-work-tree") + if err != nil { + t.Fatalf("Run: %v", err) + } + if strings.TrimSpace(string(out)) != "true" { + t.Errorf("output = %q, want true", out) + } + }) + + t.Run("returns_error_output", func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + g := initRepo(t, tmp) + + if _, err := g.Run(context.Background(), "not-a-command"); err == nil { + t.Fatal("expected error for unknown command") + } + }) +} + func TestGit_EnsureGitConfigOnce(t *testing.T) { t.Parallel() diff --git a/internal/lnkerror/error.go b/internal/lnkerror/error.go index cb64b28..6ac5f2d 100644 --- a/internal/lnkerror/error.go +++ b/internal/lnkerror/error.go @@ -23,6 +23,10 @@ var ( ErrOutsideGitRepo = errors.New("file is outside any git repo") ErrProjectScopeNotImplemented = errors.New("project scope is not yet implemented") ErrNoPatterns = errors.New("no patterns defined") + ErrIsLnkRepository = errors.New("directory is the lnk repository") + ErrOutsideProject = errors.New("path is outside the project") + ErrEmptyPattern = errors.New("pattern is empty") + ErrSyncFailed = errors.New("some files failed to sync") ) // Error wraps a sentinel error with optional context for display. diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 3ebba82..3cd0135 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -4,11 +4,14 @@ package resolver import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "net/url" "os/exec" "path" + "path/filepath" "strings" "time" ) @@ -59,3 +62,17 @@ func NormalizeRemoteURL(u string) string { u = strings.Trim(u, "/") return strings.ToLower(u) } + +// LocalProjectID returns a deterministic identifier for a repository without +// an origin remote, derived from the canonical path of the repo root. Local +// IDs are machine-specific by design: two machines checking out the same +// local-only repo to different paths produce different IDs. +func LocalProjectID(root string) string { + canonical, err := filepath.EvalSymlinks(root) + if err != nil { + canonical = root + } + sum := sha256.Sum256([]byte(canonical)) + base := strings.ToLower(filepath.Base(canonical)) + return fmt.Sprintf("local/%s-%s", base, hex.EncodeToString(sum[:])[:8]) +} diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 790b3da..a871578 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/polymorcodeus/lnk/internal/resolver" @@ -86,6 +87,26 @@ func TestResolveProjectID_NoOrigin(t *testing.T) { } } +func TestLocalProjectID(t *testing.T) { + dir := t.TempDir() + + id := resolver.LocalProjectID(dir) + base := strings.ToLower(filepath.Base(dir)) + if !strings.HasPrefix(id, "local/"+base+"-") { + t.Errorf("LocalProjectID = %q, want local/%s- prefix", id, base) + } + + again := resolver.LocalProjectID(dir) + if id != again { + t.Errorf("LocalProjectID not deterministic: %q vs %q", id, again) + } + + other := resolver.LocalProjectID(filepath.Join(dir, "other")) + if id == other { + t.Errorf("expected distinct IDs for distinct roots, both %q", id) + } +} + func initGitRepo(t *testing.T, dir string) { t.Helper() cmds := [][]string{ diff --git a/service/add.go b/service/add.go index 4fe5f87..ad2dbda 100644 --- a/service/add.go +++ b/service/add.go @@ -2,8 +2,10 @@ package service import ( "context" + "errors" "fmt" "os" + "path/filepath" "strings" "github.com/polymorcodeus/lnk/internal/filemanager" @@ -41,6 +43,14 @@ func (s *Service) Add(ctx context.Context, host string, paths []string) error { if inside { return lnkerror.WithPathAndSuggestion(lnkerror.ErrInsideGitRepo, file.RelativePath, fmt.Sprintf("inside git repo %s; use 'lnk project add' from within the project", gitRoot)) } + } else if errors.Is(err, os.ErrNotExist) { + // The path does not exist (e.g. a glob or ! negation intended for + // project scope). If it lives inside a git repo, host scope could + // never manage it anyway, so point at the right command. + inside, gitRoot, err := gitboundary.IsInsideGitRepo(ctx, filepath.Dir(file.AbsPath)) + if err == nil && inside { + return lnkerror.WithPathAndSuggestion(lnkerror.ErrInsideGitRepo, file.RelativePath, fmt.Sprintf("inside git repo %s; use 'lnk project add' from within the project", gitRoot)) + } } owner, err := s.findOwner(file.RelativePath) diff --git a/service/add_test.go b/service/add_test.go index 7ab629d..919a1b5 100644 --- a/service/add_test.go +++ b/service/add_test.go @@ -527,6 +527,24 @@ func TestAdd_RefusesProjectFile(t *testing.T) { } } +func TestAdd_NonExistentPathInsideGitRepo(t *testing.T) { + svc, home := testhelpers.TestHome(t) + + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + testhelpers.InitGitRepo(t, repoDir) + + // A ! negation (or any pattern) is project-scope syntax; host scope should + // point at the right command instead of a bare "file not found". + err := svc.Add(context.Background(), "", []string{filepath.Join(repoDir, "!AGENTS.md")}) + if err == nil { + t.Fatal("expected error for pattern-like path inside a git repo") + } + if !errors.Is(err, lnkerror.ErrInsideGitRepo) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrInsideGitRepo) + } +} + func TestAdd_AcceptsDotfileFromInsideRepo(t *testing.T) { svc, home := testhelpers.TestHome(t) repoPath := svc.RepoPath() diff --git a/service/project.go b/service/project.go index 2160b12..668a9a7 100644 --- a/service/project.go +++ b/service/project.go @@ -11,6 +11,7 @@ import ( "strings" fspkg "github.com/polymorcodeus/lnk/internal/fs" + gitpkg "github.com/polymorcodeus/lnk/internal/git" "github.com/polymorcodeus/lnk/internal/gitboundary" "github.com/polymorcodeus/lnk/internal/lnkerror" "github.com/polymorcodeus/lnk/internal/patterns" @@ -29,24 +30,17 @@ func NewProjectService(svc *Service) *ProjectService { return &ProjectService{svc: svc} } -// ProjectInit activates project scope for projectRoot by creating an empty -// ./.lnkinclude file if one does not already exist. It returns true when the -// file was created and false when it already existed. +// ProjectInit activates project scope for the git repo containing +// projectRoot by creating an empty .lnkinclude file at the repo root if one +// does not already exist. It returns true when the file was created and +// false when it already existed. func (ps *ProjectService) ProjectInit(ctx context.Context, projectRoot string) (bool, error) { - projectRoot, err := filepath.Abs(projectRoot) + root, err := ps.resolveProjectRoot(ctx, projectRoot) if err != nil { - return false, fmt.Errorf("resolve project root: %w", err) - } - - if err := ps.requireInsideGitRepo(ctx, projectRoot); err != nil { return false, err } - if _, err := resolver.ResolveProjectID(ctx, projectRoot); err != nil { - return false, fmt.Errorf("resolve project id: %w", err) - } - - manifest := filepath.Join(projectRoot, ".lnkinclude") + manifest := filepath.Join(root, ".lnkinclude") if _, err := os.Stat(manifest); err == nil { return false, nil } else if !errors.Is(err, os.ErrNotExist) { @@ -59,66 +53,61 @@ func (ps *ProjectService) ProjectInit(ctx context.Context, projectRoot string) ( return true, nil } -// ProjectAddPattern appends a pattern to the project's ./.lnkinclude file. -// Absolute paths inside the project are normalized to project-relative form. -// It returns the normalized pattern that was written. -func (ps *ProjectService) ProjectAddPattern(ctx context.Context, projectRoot, rawPattern string) (string, error) { - projectRoot, err := filepath.Abs(projectRoot) +// ProjectAddPattern appends a pattern to the project's .lnkinclude file at +// the git root. Existing on-disk paths inside the project are normalized to +// project-relative form; anything else (globs, ! negations, files that do +// not exist yet) is stored verbatim. It returns the stored pattern and +// whether it matches at least one existing file (always true for negations, +// which are not match-checked). +func (ps *ProjectService) ProjectAddPattern(ctx context.Context, projectRoot, rawPattern string) (string, bool, error) { + root, err := ps.resolveProjectRoot(ctx, projectRoot) if err != nil { - return "", fmt.Errorf("resolve project root: %w", err) - } - - if err := ps.requireInsideGitRepo(ctx, projectRoot); err != nil { - return "", err - } - - if _, err := resolver.ResolveProjectID(ctx, projectRoot); err != nil { - return "", fmt.Errorf("resolve project id: %w", err) + return "", false, err } - pattern, err := ps.normalizePattern(projectRoot, rawPattern) + pattern, err := normalizePattern(root, rawPattern) if err != nil { - return "", err + return "", false, err } - manifest := filepath.Join(projectRoot, ".lnkinclude") + manifest := filepath.Join(root, ".lnkinclude") existing, err := patterns.Load(manifest) if err != nil { - return "", fmt.Errorf("load .lnkinclude: %w", err) + return "", false, fmt.Errorf("load .lnkinclude: %w", err) } if slices.Contains(existing, pattern) { - return "", lnkerror.WithPath(lnkerror.ErrAlreadyManaged, pattern) + return "", false, lnkerror.WithPath(lnkerror.ErrAlreadyManaged, pattern) } - if err := ps.appendPattern(manifest, pattern); err != nil { - return "", err + matched := true + if !strings.HasPrefix(pattern, "!") { + matched, err = matchesAnyFile(root, pattern) + if err != nil { + return "", false, err + } + } + + if err := appendPattern(manifest, pattern); err != nil { + return "", false, err } - return pattern, nil + return pattern, matched, nil } // ProjectListPatterns returns the effective patterns for a project, split -// into global (lnk repo root) and local (./.lnkinclude) lists. +// into global (lnk repo root) and local (.lnkinclude at the git root) lists. func (ps *ProjectService) ProjectListPatterns(ctx context.Context, projectRoot string) (global, local []string, err error) { - projectRoot, err = filepath.Abs(projectRoot) + root, err := ps.resolveProjectRoot(ctx, projectRoot) if err != nil { - return nil, nil, fmt.Errorf("resolve project root: %w", err) - } - - if err := ps.requireInsideGitRepo(ctx, projectRoot); err != nil { return nil, nil, err } - if _, err := resolver.ResolveProjectID(ctx, projectRoot); err != nil { - return nil, nil, fmt.Errorf("resolve project id: %w", err) - } - global, err = patterns.Load(filepath.Join(ps.svc.RepoPath(), ".lnkinclude")) if err != nil { return nil, nil, fmt.Errorf("load global .lnkinclude: %w", err) } - local, err = patterns.Load(filepath.Join(projectRoot, ".lnkinclude")) + local, err = patterns.Load(filepath.Join(root, ".lnkinclude")) if err != nil { return nil, nil, fmt.Errorf("load local .lnkinclude: %w", err) } @@ -126,17 +115,17 @@ func (ps *ProjectService) ProjectListPatterns(ctx context.Context, projectRoot s return global, local, nil } -// ProjectUntrackPattern removes a pattern from the project's ./.lnkinclude. -// It returns removed=true when the pattern was removed from the local file. -// When the pattern exists only in the global file, it returns removed=false -// and isGlobal=true so the caller can print a warning. -func (ps *ProjectService) ProjectUntrackPattern(projectRoot, pattern string) (removed, isGlobal bool, err error) { - projectRoot, err = filepath.Abs(projectRoot) +// ProjectUntrackPattern removes a pattern from the project's .lnkinclude at +// the git root. It returns removed=true when the pattern was removed from +// the local file. When the pattern exists only in the global file, it +// returns removed=false and isGlobal=true so the caller can print a warning. +func (ps *ProjectService) ProjectUntrackPattern(ctx context.Context, projectRoot, pattern string) (removed, isGlobal bool, err error) { + root, err := ps.resolveProjectRoot(ctx, projectRoot) if err != nil { - return false, false, fmt.Errorf("resolve project root: %w", err) + return false, false, err } - localPath := filepath.Join(projectRoot, ".lnkinclude") + localPath := filepath.Join(root, ".lnkinclude") localLines, err := patterns.Load(localPath) if err != nil { return false, false, fmt.Errorf("load local .lnkinclude: %w", err) @@ -145,7 +134,7 @@ func (ps *ProjectService) ProjectUntrackPattern(projectRoot, pattern string) (re removed = slices.Contains(localLines, pattern) if removed { - if err := ps.rewritePatterns(localPath, localLines, pattern); err != nil { + if err := rewritePatterns(localPath, localLines, pattern); err != nil { return false, false, err } return true, false, nil @@ -162,54 +151,194 @@ func (ps *ProjectService) ProjectUntrackPattern(projectRoot, pattern string) (re return false, false, lnkerror.WithPath(lnkerror.ErrNotManaged, pattern) } -// requireInsideGitRepo returns an error if projectRoot is not inside a git repo. -func (ps *ProjectService) requireInsideGitRepo(ctx context.Context, projectRoot string) error { - inside, _, err := gitboundary.IsInsideGitRepo(ctx, projectRoot) +// resolveProjectRoot anchors a project command at the root of the git +// working tree containing dir, and refuses the lnk repository itself: +// treating it as a project would store the repo inside its own storage. +func (ps *ProjectService) resolveProjectRoot(ctx context.Context, dir string) (string, error) { + abs, err := filepath.Abs(dir) if err != nil { - return err + return "", fmt.Errorf("resolve project root: %w", err) + } + + root, err := gitboundary.ResolveGitRoot(ctx, abs) + if err != nil { + return "", err } - if !inside { - return lnkerror.WithPathAndSuggestion(lnkerror.ErrOutsideGitRepo, projectRoot, "use 'lnk add' for host/common scope") + if root == "" { + return "", lnkerror.WithPathAndSuggestion(lnkerror.ErrOutsideGitRepo, abs, "use 'lnk add' for host/common scope") } - return nil + + if ps.isLnkRepoRoot(root) { + return "", lnkerror.WithPathAndSuggestion(lnkerror.ErrIsLnkRepository, root, "the lnk repo manages itself; project scope is for other git repositories") + } + + return root, nil } -// normalizePattern converts an absolute path inside the project to a -// project-relative pattern. Patterns that are already relative or that lie -// outside the project are returned unchanged. -func (ps *ProjectService) normalizePattern(projectRoot, rawPattern string) (string, error) { - if !filepath.IsAbs(rawPattern) { - absPattern, err := filepath.Abs(filepath.Join(projectRoot, rawPattern)) - if err != nil { - return "", fmt.Errorf("resolve pattern %s: %w", rawPattern, err) - } - rel, err := filepath.Rel(projectRoot, absPattern) +// isLnkRepoRoot reports whether root is an lnk repository: either it carries +// the .lnkrepo marker (including clones of the repo elsewhere on disk) or it +// resolves to the configured repo path. +func (ps *ProjectService) isLnkRepoRoot(root string) bool { + if _, err := os.Stat(filepath.Join(root, repoMarkerFile)); err == nil { + return true + } + repoPath, err := filepath.EvalSymlinks(ps.svc.RepoPath()) + if err != nil { + return false + } + canonicalRoot, err := filepath.EvalSymlinks(root) + if err != nil { + return false + } + return repoPath == canonicalRoot +} + +// projectID resolves the storage identifier for the project, falling back to +// a local path-derived identifier when the repo has no origin remote. +func (ps *ProjectService) projectID(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 "", fmt.Errorf("resolve project id: %w", err) + } + return id, nil +} + +// normalizePattern converts raw into the pattern stored in .lnkinclude. When +// raw names an existing file or directory it is relativized to the project +// root, and existing paths outside the project are rejected. Anything else +// (glob patterns, ! negations, files that do not exist yet) is returned +// verbatim. +func normalizePattern(projectRoot, raw string) (string, error) { + pattern := strings.TrimSpace(raw) + body := strings.TrimPrefix(pattern, "!") + if body == "" { + return "", lnkerror.Wrap(lnkerror.ErrEmptyPattern) + } + + candidate := body + if !filepath.IsAbs(candidate) { + candidate = filepath.Join(projectRoot, candidate) + } + if _, err := os.Lstat(candidate); err != nil { + return pattern, nil + } + + // Canonicalize the directory portion of both sides: git reports the repo + // root with symlinks resolved, while the user may pass a path through a + // symlinked prefix. The leaf is never resolved: an already-managed file + // is a symlink into lnk storage and must still relativize to its live + // path inside the project. + canonicalRoot, err := filepath.EvalSymlinks(projectRoot) + if err != nil { + canonicalRoot = projectRoot + } + canonicalDir, err := filepath.EvalSymlinks(filepath.Dir(candidate)) + if err != nil { + canonicalDir = filepath.Dir(candidate) + } + canonicalCandidate := filepath.Join(canonicalDir, filepath.Base(candidate)) + + rel, err := filepath.Rel(canonicalRoot, canonicalCandidate) + if err != nil { + return "", fmt.Errorf("relativize pattern %s: %w", raw, err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", lnkerror.WithPathAndSuggestion(lnkerror.ErrOutsideProject, raw, "patterns must match files inside the project") + } + if strings.HasPrefix(pattern, "!") { + return "!" + rel, nil + } + return rel, nil +} + +// errPatternMatched stops the walk early once matchesAnyFile finds a hit. +var errPatternMatched = errors.New("pattern matched") + +// matchesAnyFile reports whether pattern matches at least one existing file +// in the project. +func matchesAnyFile(root, pattern string) (bool, error) { + err := walkProjectFiles(root, func(_, rel string) error { + ok, err := patterns.Match([]string{pattern}, rel) if err != nil { - return "", fmt.Errorf("relativize pattern %s: %w", rawPattern, err) + return err } - if !strings.HasPrefix(rel, "..") && rel != ".." { - return rel, nil + if ok { + return errPatternMatched } - return rawPattern, nil + return nil + }) + if errors.Is(err, errPatternMatched) { + return true, nil } - - absPattern, err := filepath.Abs(rawPattern) if err != nil { - return "", fmt.Errorf("resolve pattern %s: %w", rawPattern, err) + return false, err } - rel, err := filepath.Rel(projectRoot, absPattern) + return false, nil +} + +// walkProjectFiles calls fn for every regular, non-symlinked file under +// root, pruning .git directories and nested git working trees. The rel path +// passed to fn uses '/' separators relative to root. +func walkProjectFiles(root string, fn func(absPath, rel string) error) error { + return filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.IsDir() { + if info.Name() == ".git" { + return filepath.SkipDir + } + if path != root && hasGitMarker(path) { + return filepath.SkipDir + } + return nil + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + return fn(path, filepath.ToSlash(rel)) + }) +} + +// hasGitMarker reports whether dir is the root of a git working tree: a +// .git directory, or a .git file as used by submodules and linked worktrees. +func hasGitMarker(dir string) bool { + _, err := os.Lstat(filepath.Join(dir, ".git")) + return err == nil +} + +// implicitlyExcluded reports whether rel is lnk metadata that must never be +// managed, regardless of the effective patterns. +func implicitlyExcluded(rel string) bool { + return rel == ".lnkinclude" || strings.HasSuffix(rel, ".lnk-backup") +} + +// projectTrackedFiles returns the set of slash-separated, root-relative +// paths tracked by the project's own git index. +func projectTrackedFiles(ctx context.Context, root string) (map[string]struct{}, error) { + out, err := gitpkg.New(root).Run(ctx, "ls-files", "-z") if err != nil { - return "", fmt.Errorf("relativize pattern %s: %w", rawPattern, err) + return nil, fmt.Errorf("list project-tracked files: %w\n%s", err, out) } - if strings.HasPrefix(rel, "..") || rel == ".." { - return "", lnkerror.WithPathAndSuggestion(lnkerror.ErrOutsideGitRepo, rawPattern, "pattern must be inside the project") + tracked := make(map[string]struct{}) + for p := range strings.SplitSeq(string(out), "\x00") { + if p != "" { + tracked[p] = struct{}{} + } } - return rel, nil + return tracked, nil } -// appendPattern appends pattern to manifest, ensuring a preceding newline when -// the file already exists and does not end with one. -func (ps *ProjectService) appendPattern(manifest, pattern string) error { +// appendPattern appends pattern to manifest, ensuring a preceding newline +// when the file already exists and does not end with one. +func appendPattern(manifest, pattern string) error { f, err := os.OpenFile(manifest, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return fmt.Errorf("open .lnkinclude: %w", err) @@ -235,7 +364,7 @@ func (ps *ProjectService) appendPattern(manifest, pattern string) error { } // rewritePatterns writes lines back to manifest, excluding dropPattern. -func (ps *ProjectService) rewritePatterns(manifest string, lines []string, dropPattern string) error { +func rewritePatterns(manifest string, lines []string, dropPattern string) error { f, err := os.Create(manifest) if err != nil { return fmt.Errorf("rewrite .lnkinclude: %w", err) @@ -264,24 +393,27 @@ func (ps *ProjectService) rewritePatterns(manifest string, lines []string, dropP type ProjectPushResult struct { ProjectID string Synced []string + // SkippedTracked lists matched files left untouched because the + // project's own git index tracks them (requires force to manage). + SkippedTracked []string } // ProjectPush walks the project repository, moves matching files to the lnk // project storage directory, and symlinks them back. It then stages and -// commits the changes in the lnk repo. -func (ps *ProjectService) ProjectPush(ctx context.Context, projectRoot string) (ProjectPushResult, error) { - projectRoot, err := filepath.Abs(projectRoot) +// commits the changes in the lnk repo. Files tracked by the project's own +// git index are skipped unless force is set, since replacing them with +// symlinks would dirty the project's working tree with a typechange. Files +// that fail to move are collected and reported as an aggregate error after +// the rest are synced. +func (ps *ProjectService) ProjectPush(ctx context.Context, projectRoot string, force bool) (ProjectPushResult, error) { + root, err := ps.resolveProjectRoot(ctx, projectRoot) if err != nil { - return ProjectPushResult{}, fmt.Errorf("resolve project root: %w", err) - } - - if err := ps.requireInsideGitRepo(ctx, projectRoot); err != nil { return ProjectPushResult{}, err } - id, err := resolver.ResolveProjectID(ctx, projectRoot) + id, err := ps.projectID(ctx, root) if err != nil { - return ProjectPushResult{}, fmt.Errorf("resolve project id: %w", err) + return ProjectPushResult{}, err } storageDir := filepath.Join(ps.svc.RepoPath(), "projects", id) @@ -289,98 +421,51 @@ func (ps *ProjectService) ProjectPush(ctx context.Context, projectRoot string) ( return ProjectPushResult{}, fmt.Errorf("create project storage: %w", err) } - resolver := &scope.ProjectRootResolver{ - GitRoot: projectRoot, - StorageDir: storageDir, - } - global, err := patterns.Load(filepath.Join(ps.svc.RepoPath(), ".lnkinclude")) if err != nil { return ProjectPushResult{}, fmt.Errorf("load global .lnkinclude: %w", err) } - local, err := patterns.Load(filepath.Join(projectRoot, ".lnkinclude")) + local, err := patterns.Load(filepath.Join(root, ".lnkinclude")) if err != nil { return ProjectPushResult{}, fmt.Errorf("load local .lnkinclude: %w", err) } - effective := append(global, local...) + effective := slices.Concat(global, local) if len(effective) == 0 { return ProjectPushResult{}, lnkerror.Wrap(lnkerror.ErrNoPatterns) } + tracked, err := projectTrackedFiles(ctx, root) + if err != nil { + return ProjectPushResult{}, err + } + result := ProjectPushResult{ProjectID: id} fs := &fspkg.FileSystem{} + var failed []error - err = filepath.Walk(projectRoot, func(path string, info os.FileInfo, walkErr error) error { - if walkErr != nil { + err = walkProjectFiles(root, func(path, rel string) error { + if implicitlyExcluded(rel) { return nil } - if info.IsDir() { - if info.Name() == ".git" { - return filepath.SkipDir - } - return nil - } - if info.Mode()&os.ModeSymlink != 0 { - return nil - } - - rel, err := resolver.ToStorage(path) - if err != nil { - return nil - } - rel = filepath.ToSlash(rel) - match, err := patterns.Match(effective, rel) - if err != nil || !match { - return nil - } - - storagePath := filepath.Join(storageDir, rel) - - liveInfo, err := os.Lstat(path) if err != nil { - return nil - } - - if liveInfo.Mode()&os.ModeSymlink != 0 { - target, err := os.Readlink(path) - if err == nil { - if !filepath.IsAbs(target) { - target = filepath.Join(filepath.Dir(path), target) - } - target = filepath.Clean(target) - if target == storagePath { - if _, err := os.Stat(storagePath); err == nil { - return nil - } - } - } - if err := os.Remove(path); err != nil { - return nil - } + return err } - - if err := os.MkdirAll(filepath.Dir(storagePath), 0o755); err != nil { + if !match { return nil } - - if moveErr := fs.MoveFile(path, storagePath); moveErr != nil { - if _, statErr := os.Stat(storagePath); statErr == nil { - if err := fs.CreateSymlink(storagePath, path); err != nil { - return nil - } - result.Synced = append(result.Synced, rel) + if !force { + if _, ok := tracked[rel]; ok { + result.SkippedTracked = append(result.SkippedTracked, rel) + return nil } - return nil } - - if err := fs.CreateSymlink(storagePath, path); err != nil { - _ = fs.MoveFile(storagePath, path) + if err := moveToStorage(fs, path, filepath.Join(storageDir, filepath.FromSlash(rel))); err != nil { + failed = append(failed, fmt.Errorf("%s: %w", rel, err)) return nil } - result.Synced = append(result.Synced, rel) return nil }) @@ -396,36 +481,65 @@ func (ps *ProjectService) ProjectPush(ctx context.Context, projectRoot string) ( if err != nil { return result, err } - if !hasChanges { - return result, nil + if hasChanges { + if err := ps.svc.commit(ctx, "lnk: sync project "+id); err != nil { + return result, err + } } - if err := ps.svc.commit(ctx, "lnk: sync project "+id); err != nil { - return result, err + if len(failed) > 0 { + return result, fmt.Errorf("%w: %w", lnkerror.ErrSyncFailed, errors.Join(failed...)) } return result, nil } -// ProjectRestore recreates symlinks for all project-scoped files from storage. -func (ps *ProjectService) ProjectRestore(ctx context.Context, projectRoot string, dryRun bool) (RestoreInfo, error) { - projectRoot, err := filepath.Abs(projectRoot) +// moveToStorage moves livePath to storagePath and symlinks it back. If +// linking fails the move is rolled back so the live file is never lost. +func moveToStorage(fs *fspkg.FileSystem, livePath, storagePath string) error { + if err := os.MkdirAll(filepath.Dir(storagePath), 0o755); err != nil { + return fmt.Errorf("create storage directory: %w", err) + } + if err := fs.MoveFile(livePath, storagePath); err != nil { + return fmt.Errorf("move to storage: %w", err) + } + if err := fs.CreateSymlink(storagePath, livePath); err != nil { + if rbErr := fs.MoveFile(storagePath, livePath); rbErr != nil { + return fmt.Errorf("create symlink: %w (rollback failed: %v)", err, rbErr) + } + return fmt.Errorf("create symlink: %w", err) + } + return nil +} + +// ProjectRestore recreates symlinks for all project-scoped files from +// storage. Live files tracked by the project's own git index are left +// untouched (and reported in RestoreInfo.SkippedTracked) unless force is +// set, since replacing them with symlinks would dirty the project's working +// tree with a typechange. +func (ps *ProjectService) ProjectRestore(ctx context.Context, projectRoot string, dryRun, force bool) (RestoreInfo, error) { + root, err := ps.resolveProjectRoot(ctx, projectRoot) if err != nil { - return RestoreInfo{}, fmt.Errorf("resolve project root: %w", err) + return RestoreInfo{}, err } - id, err := resolver.ResolveProjectID(ctx, projectRoot) + id, err := ps.projectID(ctx, root) if err != nil { - return RestoreInfo{}, fmt.Errorf("resolve project id: %w", err) + return RestoreInfo{}, err } storageDir := filepath.Join(ps.svc.RepoPath(), "projects", id) if _, err := os.Stat(storageDir); os.IsNotExist(err) { - return RestoreInfo{}, lnkerror.WithPathAndSuggestion(lnkerror.ErrNotManaged, projectRoot, "run 'lnk project push' first") + return RestoreInfo{}, lnkerror.WithPathAndSuggestion(lnkerror.ErrNotManaged, root, "run 'lnk project push' first") } - resolver := &scope.ProjectRootResolver{ - GitRoot: projectRoot, + tracked, err := projectTrackedFiles(ctx, root) + if err != nil { + return RestoreInfo{}, err + } + + r := &scope.ProjectRootResolver{ + GitRoot: root, StorageDir: storageDir, } @@ -433,27 +547,40 @@ func (ps *ProjectService) ProjectRestore(ctx context.Context, projectRoot string fs := &fspkg.FileSystem{} err = filepath.Walk(storageDir, func(path string, fi os.FileInfo, walkErr error) error { - if walkErr != nil || fi.IsDir() { + if walkErr != nil { + return walkErr + } + if fi.IsDir() { return nil } rel, err := filepath.Rel(storageDir, path) if err != nil { - return nil + return err } rel = filepath.ToSlash(rel) - livePath, err := resolver.ToLive(rel) + livePath, err := r.ToLive(rel) if err != nil { - return nil + return err + } + + liveInfo, statErr := os.Lstat(livePath) + liveExists := statErr == nil + liveIsSymlink := liveExists && liveInfo.Mode()&os.ModeSymlink != 0 + + if !force && !liveIsSymlink { + if _, ok := tracked[rel]; ok { + info.SkippedTracked = append(info.SkippedTracked, rel) + return nil + } } - liveInfo, err := os.Lstat(livePath) - if err == nil { - if liveInfo.Mode()&os.ModeSymlink != 0 { + if liveExists { + if liveIsSymlink { if !dryRun { if err := os.Remove(livePath); err != nil { - return nil + return fmt.Errorf("replace symlink %s: %w", livePath, err) } } } else { @@ -491,9 +618,9 @@ func (ps *ProjectService) ProjectRestore(ctx context.Context, projectRoot string } // ProjectPull pulls the lnk repo and restores project symlinks. -func (ps *ProjectService) ProjectPull(ctx context.Context, projectRoot string) (RestoreInfo, error) { +func (ps *ProjectService) ProjectPull(ctx context.Context, projectRoot string, force bool) (RestoreInfo, error) { if err := ps.svc.git.Pull(ctx); err != nil { return RestoreInfo{}, err } - return ps.ProjectRestore(ctx, projectRoot, false) + return ps.ProjectRestore(ctx, projectRoot, false, force) } diff --git a/service/project_test.go b/service/project_test.go index 19044b4..0d9d2c1 100644 --- a/service/project_test.go +++ b/service/project_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/polymorcodeus/lnk/internal/lnkerror" @@ -92,19 +93,58 @@ func TestProjectInit_RequiresGitRepo(t *testing.T) { } } -func TestProjectInit_RequiresOrigin(t *testing.T) { +func TestProjectInit_FromSubdirAnchorsAtGitRoot(t *testing.T) { svc, home := testhelpers.TestHome(t) repoDir := filepath.Join(home, "repos", "hermes") - testhelpers.MakeDir(t, repoDir) - testhelpers.InitGitRepo(t, repoDir) + subDir := filepath.Join(repoDir, "sub", "dir") + testhelpers.MakeDir(t, subDir) + initProjectRepo(t, repoDir) ps := service.NewProjectService(svc) - _, err := ps.ProjectInit(context.Background(), repoDir) - if err == nil { - t.Fatal("expected error without origin") + created, err := ps.ProjectInit(context.Background(), subDir) + if err != nil { + t.Fatalf("ProjectInit: %v", err) + } + if !created { + t.Error("expected created=true") + } + + if !testhelpers.FileExists(t, filepath.Join(repoDir, ".lnkinclude")) { + t.Error("expected .lnkinclude at the git root") } - if !errors.Is(err, resolver.ErrNoOrigin) { - t.Errorf("error = %v, want %v", err, resolver.ErrNoOrigin) + if testhelpers.FileExists(t, filepath.Join(subDir, ".lnkinclude")) { + t.Error("expected no .lnkinclude in the subdirectory") + } +} + +func TestProjectCommands_RefuseLnkRepository(t *testing.T) { + svc, home := testhelpers.TestHome(t) + cloneDir := filepath.Join(home, "elsewhere", "dotfiles-clone") + + // A copy of the lnk repo at a different path still carries the marker. + testhelpers.MakeDir(t, cloneDir) + testhelpers.InitGitRepo(t, cloneDir) + if err := os.WriteFile(filepath.Join(cloneDir, ".lnkrepo"), []byte("version=2\n"), 0o644); err != nil { + t.Fatal(err) + } + + for _, dir := range []string{svc.RepoPath(), cloneDir} { + ps := service.NewProjectService(svc) + if _, err := ps.ProjectInit(context.Background(), dir); !errors.Is(err, lnkerror.ErrIsLnkRepository) { + t.Errorf("ProjectInit(%s) error = %v, want %v", dir, err, lnkerror.ErrIsLnkRepository) + } + if _, err := ps.ProjectPush(context.Background(), dir, false); !errors.Is(err, lnkerror.ErrIsLnkRepository) { + t.Errorf("ProjectPush(%s) error = %v, want %v", dir, err, lnkerror.ErrIsLnkRepository) + } + if _, _, err := ps.ProjectListPatterns(context.Background(), dir); !errors.Is(err, lnkerror.ErrIsLnkRepository) { + t.Errorf("ProjectListPatterns(%s) error = %v, want %v", dir, err, lnkerror.ErrIsLnkRepository) + } + if _, _, err := ps.ProjectUntrackPattern(context.Background(), dir, "x"); !errors.Is(err, lnkerror.ErrIsLnkRepository) { + t.Errorf("ProjectUntrackPattern(%s) error = %v, want %v", dir, err, lnkerror.ErrIsLnkRepository) + } + if _, err := ps.ProjectRestore(context.Background(), dir, false, false); !errors.Is(err, lnkerror.ErrIsLnkRepository) { + t.Errorf("ProjectRestore(%s) error = %v, want %v", dir, err, lnkerror.ErrIsLnkRepository) + } } } @@ -114,16 +154,20 @@ func TestProjectAddPattern_AppendsAndNormalizes(t *testing.T) { testhelpers.MakeDir(t, repoDir) initProjectRepo(t, repoDir) - ps := service.NewProjectService(svc) - absPattern := filepath.Join(repoDir, ".cursor", "rules.md") + liveFile := filepath.Join(repoDir, ".cursor", "rules.md") + testhelpers.MakeFile(t, liveFile, "# rules\n") - normalized, err := ps.ProjectAddPattern(context.Background(), repoDir, absPattern) + ps := service.NewProjectService(svc) + normalized, matched, err := ps.ProjectAddPattern(context.Background(), repoDir, liveFile) if err != nil { t.Fatalf("ProjectAddPattern: %v", err) } if normalized != filepath.Join(".cursor", "rules.md") { t.Errorf("normalized = %q, want .cursor/rules.md", normalized) } + if !matched { + t.Error("expected matched=true for an existing file") + } manifest := filepath.Join(repoDir, ".lnkinclude") content, err := os.ReadFile(manifest) @@ -135,20 +179,143 @@ func TestProjectAddPattern_AppendsAndNormalizes(t *testing.T) { } } -func TestProjectAddPattern_RejectsOutsideProject(t *testing.T) { +func TestProjectAddPattern_StoresPatternsVerbatim(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + + // A ! negation is not a path and must be stored as written. + negation, matched, err := ps.ProjectAddPattern(context.Background(), repoDir, "!AGENTS.md") + if err != nil { + t.Fatalf("add negation: %v", err) + } + if negation != "!AGENTS.md" { + t.Errorf("negation = %q, want !AGENTS.md", negation) + } + if !matched { + t.Error("expected matched=true for negations (match check is skipped)") + } + + // A glob for a directory that does not exist yet keeps its trailing slash. + glob, matched, err := ps.ProjectAddPattern(context.Background(), repoDir, ".todo/") + if err != nil { + t.Fatalf("add glob: %v", err) + } + if glob != ".todo/" { + t.Errorf("glob = %q, want .todo/", glob) + } + if matched { + t.Error("expected matched=false for a pattern with no current files") + } + + manifest := filepath.Join(repoDir, ".lnkinclude") + content, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + if string(content) != "!AGENTS.md\n.todo/\n" { + t.Errorf(".lnkinclude content = %q", string(content)) + } +} + +func TestProjectAddPattern_RelativizesExistingDir(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + testhelpers.MakeFile(t, filepath.Join(repoDir, ".todo", "a.md"), "a\n") + + ps := service.NewProjectService(svc) + normalized, matched, err := ps.ProjectAddPattern(context.Background(), repoDir, ".todo/") + if err != nil { + t.Fatalf("add: %v", err) + } + if normalized != ".todo" { + t.Errorf("normalized = %q, want .todo", normalized) + } + if !matched { + t.Error("expected matched=true for a dir with files") + } +} + +func TestProjectAddPattern_NegatesManagedSymlink(t *testing.T) { svc, home := testhelpers.TestHome(t) repoDir := filepath.Join(home, "repos", "hermes") testhelpers.MakeDir(t, repoDir) initProjectRepo(t, repoDir) ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, "AGENTS.md"); err != nil { + t.Fatalf("add include: %v", err) + } + testhelpers.MakeFile(t, filepath.Join(repoDir, "AGENTS.md"), "agents\n") + if _, err := ps.ProjectPush(context.Background(), repoDir, false); err != nil { + t.Fatalf("ProjectPush: %v", err) + } + + // AGENTS.md is now a symlink into lnk storage (outside the project). + // Negating it must still relativize to its live path. + negation, _, err := ps.ProjectAddPattern(context.Background(), repoDir, "!AGENTS.md") + if err != nil { + t.Fatalf("add negation: %v", err) + } + if negation != "!AGENTS.md" { + t.Errorf("negation = %q, want !AGENTS.md", negation) + } +} + +func TestProjectAddPattern_DotDotFilenameInsideProject(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + testhelpers.MakeFile(t, filepath.Join(repoDir, "..foo"), "odd name\n") + + ps := service.NewProjectService(svc) + normalized, _, err := ps.ProjectAddPattern(context.Background(), repoDir, "..foo") + if err != nil { + t.Fatalf("add: %v", err) + } + if normalized != "..foo" { + t.Errorf("normalized = %q, want ..foo", normalized) + } +} + +func TestProjectAddPattern_RejectsOutsideProject(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + outside := filepath.Join(home, ".bashrc") - _, err := ps.ProjectAddPattern(context.Background(), repoDir, outside) + testhelpers.MakeFile(t, outside, "# bashrc\n") + + ps := service.NewProjectService(svc) + _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, outside) if err == nil { t.Fatal("expected error for pattern outside project") } - if !errors.Is(err, lnkerror.ErrOutsideGitRepo) { - t.Errorf("error = %v, want %v", err, lnkerror.ErrOutsideGitRepo) + if !errors.Is(err, lnkerror.ErrOutsideProject) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrOutsideProject) + } +} + +func TestProjectAddPattern_RejectsEmpty(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + for _, pattern := range []string{"", " ", "!"} { + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, pattern); !errors.Is(err, lnkerror.ErrEmptyPattern) { + t.Errorf("add %q error = %v, want %v", pattern, err, lnkerror.ErrEmptyPattern) + } } } @@ -160,11 +327,11 @@ func TestProjectAddPattern_RejectsDuplicate(t *testing.T) { ps := service.NewProjectService(svc) pattern := ".cursor/**" - if _, err := ps.ProjectAddPattern(context.Background(), repoDir, pattern); err != nil { + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, pattern); err != nil { t.Fatalf("first add: %v", err) } - _, err := ps.ProjectAddPattern(context.Background(), repoDir, pattern) + _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, pattern) if err == nil { t.Fatal("expected error for duplicate pattern") } @@ -185,7 +352,7 @@ func TestProjectListPatterns_SplitsGlobalAndLocal(t *testing.T) { } ps := service.NewProjectService(svc) - if _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { t.Fatalf("add: %v", err) } @@ -201,6 +368,27 @@ func TestProjectListPatterns_SplitsGlobalAndLocal(t *testing.T) { } } +func TestProjectListPatterns_FromSubdirSeesRootManifest(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + subDir := filepath.Join(repoDir, "sub") + testhelpers.MakeDir(t, subDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { + t.Fatalf("add: %v", err) + } + + _, local, err := ps.ProjectListPatterns(context.Background(), subDir) + if err != nil { + t.Fatalf("ProjectListPatterns: %v", err) + } + if len(local) != 1 || local[0] != ".cursor/**" { + t.Errorf("local = %v, want [.cursor/**]", local) + } +} + func TestProjectUntrackPattern_RemovesLocal(t *testing.T) { svc, home := testhelpers.TestHome(t) repoDir := filepath.Join(home, "repos", "hermes") @@ -208,11 +396,11 @@ func TestProjectUntrackPattern_RemovesLocal(t *testing.T) { initProjectRepo(t, repoDir) ps := service.NewProjectService(svc) - if _, err := ps.ProjectAddPattern(context.Background(), repoDir, "agents.md"); err != nil { + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, "agents.md"); err != nil { t.Fatalf("add: %v", err) } - removed, isGlobal, err := ps.ProjectUntrackPattern(repoDir, "agents.md") + removed, isGlobal, err := ps.ProjectUntrackPattern(context.Background(), repoDir, "agents.md") if err != nil { t.Fatalf("ProjectUntrackPattern: %v", err) } @@ -236,7 +424,7 @@ func TestProjectUntrackPattern_WarnsForGlobal(t *testing.T) { } ps := service.NewProjectService(svc) - removed, isGlobal, err := ps.ProjectUntrackPattern(repoDir, ".todo/**") + removed, isGlobal, err := ps.ProjectUntrackPattern(context.Background(), repoDir, ".todo/**") if err != nil { t.Fatalf("ProjectUntrackPattern: %v", err) } @@ -255,7 +443,7 @@ func TestProjectUntrackPattern_ErrorsWhenMissing(t *testing.T) { initProjectRepo(t, repoDir) ps := service.NewProjectService(svc) - _, _, err := ps.ProjectUntrackPattern(repoDir, "missing.md") + _, _, err := ps.ProjectUntrackPattern(context.Background(), repoDir, "missing.md") if err == nil { t.Fatal("expected error for missing pattern") } @@ -271,14 +459,14 @@ func TestProjectPush_MovesMatchingFileToStorage(t *testing.T) { initProjectRepo(t, repoDir) ps := service.NewProjectService(svc) - if _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { t.Fatalf("add pattern: %v", err) } liveFile := filepath.Join(repoDir, ".cursor", "rules.md") testhelpers.MakeFile(t, liveFile, "# rules\n") - result, err := ps.ProjectPush(context.Background(), repoDir) + result, err := ps.ProjectPush(context.Background(), repoDir, false) if err != nil { t.Fatalf("ProjectPush: %v", err) } @@ -297,6 +485,40 @@ func TestProjectPush_MovesMatchingFileToStorage(t *testing.T) { } } +func TestProjectPush_FromSubdirStoresRootRelative(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + subDir := filepath.Join(repoDir, "sub") + testhelpers.MakeDir(t, subDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, "notes.md"); err != nil { + t.Fatalf("add pattern: %v", err) + } + testhelpers.MakeFile(t, filepath.Join(repoDir, "notes.md"), "notes\n") + testhelpers.MakeFile(t, filepath.Join(subDir, "notes.md"), "sub notes\n") + + result, err := ps.ProjectPush(context.Background(), subDir, false) + if err != nil { + t.Fatalf("ProjectPush: %v", err) + } + if len(result.Synced) != 2 { + t.Fatalf("synced = %v, want 2 entries", result.Synced) + } + + id, err := resolver.ResolveProjectID(context.Background(), repoDir) + if err != nil { + t.Fatalf("resolve project id: %v", err) + } + for _, rel := range []string{"notes.md", filepath.Join("sub", "notes.md")} { + storageFile := filepath.Join(svc.RepoPath(), "projects", id, rel) + if !testhelpers.FileExists(t, storageFile) { + t.Errorf("expected storage file %s", storageFile) + } + } +} + func TestProjectPush_SkipsAlreadySymlinked(t *testing.T) { svc, home := testhelpers.TestHome(t) repoDir := filepath.Join(home, "repos", "hermes") @@ -304,18 +526,18 @@ func TestProjectPush_SkipsAlreadySymlinked(t *testing.T) { initProjectRepo(t, repoDir) ps := service.NewProjectService(svc) - if _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { t.Fatalf("add pattern: %v", err) } liveFile := filepath.Join(repoDir, ".cursor", "rules.md") testhelpers.MakeFile(t, liveFile, "# rules\n") - if _, err := ps.ProjectPush(context.Background(), repoDir); err != nil { + if _, err := ps.ProjectPush(context.Background(), repoDir, false); err != nil { t.Fatalf("first push: %v", err) } - result, err := ps.ProjectPush(context.Background(), repoDir) + result, err := ps.ProjectPush(context.Background(), repoDir, false) if err != nil { t.Fatalf("second push: %v", err) } @@ -331,7 +553,7 @@ func TestProjectPush_ErrorsWhenNoPatterns(t *testing.T) { initProjectRepo(t, repoDir) ps := service.NewProjectService(svc) - _, err := ps.ProjectPush(context.Background(), repoDir) + _, err := ps.ProjectPush(context.Background(), repoDir, false) if err == nil { t.Fatal("expected error when no patterns") } @@ -347,7 +569,7 @@ func TestProjectPush_SkipsGitDirectory(t *testing.T) { initProjectRepo(t, repoDir) ps := service.NewProjectService(svc) - if _, err := ps.ProjectAddPattern(context.Background(), repoDir, "*.md"); err != nil { + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, "*.md"); err != nil { t.Fatalf("add pattern: %v", err) } @@ -359,7 +581,8 @@ func TestProjectPush_SkipsGitDirectory(t *testing.T) { liveFile := filepath.Join(repoDir, "README.md") testhelpers.MakeFile(t, liveFile, "# hello\n") - result, err := ps.ProjectPush(context.Background(), repoDir) + // Force: README.md is committed to the project repo by the fixture. + result, err := ps.ProjectPush(context.Background(), repoDir, true) if err != nil { t.Fatalf("ProjectPush: %v", err) } @@ -372,35 +595,180 @@ func TestProjectPush_SkipsGitDirectory(t *testing.T) { } } -func TestProjectPush_CreatesStorageDir(t *testing.T) { +func TestProjectPush_SkipsProjectGitTrackedFiles(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, "*.md"); err != nil { + t.Fatalf("add pattern: %v", err) + } + + trackedFile := filepath.Join(repoDir, "README.md") // committed by fixture + untrackedFile := filepath.Join(repoDir, "notes.md") + testhelpers.MakeFile(t, untrackedFile, "notes\n") + + result, err := ps.ProjectPush(context.Background(), repoDir, false) + if err != nil { + t.Fatalf("ProjectPush: %v", err) + } + if len(result.Synced) != 1 || result.Synced[0] != "notes.md" { + t.Errorf("synced = %v, want [notes.md]", result.Synced) + } + if len(result.SkippedTracked) != 1 || result.SkippedTracked[0] != "README.md" { + t.Errorf("skipped = %v, want [README.md]", result.SkippedTracked) + } + + // The tracked file must remain a real file, not a symlink. + info, err := os.Lstat(trackedFile) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Error("expected tracked file to remain a real file") + } + + // Force manages it anyway. + forced, err := ps.ProjectPush(context.Background(), repoDir, true) + if err != nil { + t.Fatalf("ProjectPush --force: %v", err) + } + if len(forced.Synced) != 1 || forced.Synced[0] != "README.md" { + t.Errorf("forced synced = %v, want [README.md]", forced.Synced) + } + storageFile := filepath.Join(svc.RepoPath(), "projects", forced.ProjectID, "README.md") + testhelpers.AssertSymlink(t, trackedFile, storageFile) +} + +func TestProjectPush_PrunesNestedGitRepos(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + nested := filepath.Join(repoDir, "vendor", "lib") + testhelpers.MakeDir(t, nested) + testhelpers.InitGitRepo(t, nested) + nestedFile := filepath.Join(nested, "secret.md") + testhelpers.MakeFile(t, nestedFile, "nested\n") + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, "vendor/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + + result, err := ps.ProjectPush(context.Background(), repoDir, false) + if err != nil { + t.Fatalf("ProjectPush: %v", err) + } + if len(result.Synced) != 0 { + t.Errorf("synced = %v, want empty (nested repo pruned)", result.Synced) + } + + info, err := os.Lstat(nestedFile) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Error("expected nested repo file to remain a real file") + } +} + +func TestProjectPush_NeverStoresLnkMetadata(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, "*"); err != nil { + t.Fatalf("add pattern: %v", err) + } + + testhelpers.MakeFile(t, filepath.Join(repoDir, "notes.md"), "notes\n") + testhelpers.MakeFile(t, filepath.Join(repoDir, "old.md.lnk-backup"), "backup\n") + + result, err := ps.ProjectPush(context.Background(), repoDir, false) + if err != nil { + t.Fatalf("ProjectPush: %v", err) + } + if len(result.Synced) != 1 || result.Synced[0] != "notes.md" { + t.Errorf("synced = %v, want [notes.md]", result.Synced) + } + + storageDir := filepath.Join(svc.RepoPath(), "projects", result.ProjectID) + if testhelpers.FileExists(t, filepath.Join(storageDir, ".lnkinclude")) { + t.Error("expected .lnkinclude to never be stored") + } + if testhelpers.FileExists(t, filepath.Join(storageDir, "old.md.lnk-backup")) { + t.Error("expected .lnk-backup files to never be stored") + } + if !testhelpers.FileExists(t, filepath.Join(repoDir, ".lnkinclude")) { + t.Error("expected live .lnkinclude to remain in place") + } +} + +func TestProjectPush_ReportsMoveFailures(t *testing.T) { svc, home := testhelpers.TestHome(t) repoDir := filepath.Join(home, "repos", "hermes") testhelpers.MakeDir(t, repoDir) initProjectRepo(t, repoDir) ps := service.NewProjectService(svc) - if _, err := ps.ProjectAddPattern(context.Background(), repoDir, "*.md"); err != nil { + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, "blocked.md"); err != nil { t.Fatalf("add pattern: %v", err) } + testhelpers.MakeFile(t, filepath.Join(repoDir, "blocked.md"), "blocked\n") + // A directory at the storage path makes the move fail. id, err := resolver.ResolveProjectID(context.Background(), repoDir) if err != nil { t.Fatalf("resolve project id: %v", err) } - storageDir := filepath.Join(svc.RepoPath(), "projects", id) - if testhelpers.FileExists(t, storageDir) { - t.Fatal("expected storage dir not to exist before push") + testhelpers.MakeDir(t, filepath.Join(svc.RepoPath(), "projects", id, "blocked.md")) + + result, err := ps.ProjectPush(context.Background(), repoDir, false) + if err == nil { + t.Fatal("expected error when the move fails") + } + if !errors.Is(err, lnkerror.ErrSyncFailed) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrSyncFailed) + } + if len(result.Synced) != 0 { + t.Errorf("synced = %v, want empty", result.Synced) } - liveFile := filepath.Join(repoDir, "notes.md") - testhelpers.MakeFile(t, liveFile, "notes\n") + // The live file must not be lost. + if !testhelpers.FileExists(t, filepath.Join(repoDir, "blocked.md")) { + t.Error("expected live file to remain after failed sync") + } +} - if _, err := ps.ProjectPush(context.Background(), repoDir); err != nil { +func TestProjectPush_NoOriginUsesLocalID(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "localonly") + testhelpers.MakeDir(t, repoDir) + testhelpers.InitGitRepo(t, repoDir) // no origin remote + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, "notes.md"); err != nil { + t.Fatalf("add pattern: %v", err) + } + testhelpers.MakeFile(t, filepath.Join(repoDir, "notes.md"), "notes\n") + + result, err := ps.ProjectPush(context.Background(), repoDir, false) + if err != nil { t.Fatalf("ProjectPush: %v", err) } + if !strings.HasPrefix(result.ProjectID, "local/") { + t.Errorf("project id = %q, want local/ prefix", result.ProjectID) + } - if !testhelpers.FileExists(t, storageDir) { - t.Error("expected storage dir to be created") + storageFile := filepath.Join(svc.RepoPath(), "projects", result.ProjectID, "notes.md") + if !testhelpers.FileExists(t, storageFile) { + t.Errorf("expected storage file %s", storageFile) } } @@ -411,14 +779,14 @@ func TestProjectRestore_RecreatesSymlinks(t *testing.T) { initProjectRepo(t, repoDir) ps := service.NewProjectService(svc) - if _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { t.Fatalf("add pattern: %v", err) } liveFile := filepath.Join(repoDir, ".cursor", "rules.md") testhelpers.MakeFile(t, liveFile, "# rules\n") - result, err := ps.ProjectPush(context.Background(), repoDir) + result, err := ps.ProjectPush(context.Background(), repoDir, false) if err != nil { t.Fatalf("ProjectPush: %v", err) } @@ -428,7 +796,7 @@ func TestProjectRestore_RecreatesSymlinks(t *testing.T) { t.Fatalf("remove symlink: %v", err) } - info, err := ps.ProjectRestore(context.Background(), repoDir, false) + info, err := ps.ProjectRestore(context.Background(), repoDir, false, false) if err != nil { t.Fatalf("ProjectRestore: %v", err) } @@ -445,14 +813,14 @@ func TestProjectRestore_BackupsExistingFile(t *testing.T) { initProjectRepo(t, repoDir) ps := service.NewProjectService(svc) - if _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { t.Fatalf("add pattern: %v", err) } liveFile := filepath.Join(repoDir, ".cursor", "rules.md") testhelpers.MakeFile(t, liveFile, "# rules\n") - result, err := ps.ProjectPush(context.Background(), repoDir) + result, err := ps.ProjectPush(context.Background(), repoDir, false) if err != nil { t.Fatalf("ProjectPush: %v", err) } @@ -463,7 +831,7 @@ func TestProjectRestore_BackupsExistingFile(t *testing.T) { } testhelpers.MakeFile(t, liveFile, "local changes\n") - info, err := ps.ProjectRestore(context.Background(), repoDir, false) + info, err := ps.ProjectRestore(context.Background(), repoDir, false, false) if err != nil { t.Fatalf("ProjectRestore: %v", err) } @@ -478,6 +846,72 @@ func TestProjectRestore_BackupsExistingFile(t *testing.T) { } } +func TestProjectRestore_SkipsProjectGitTrackedFiles(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + + liveFile := filepath.Join(repoDir, ".cursor", "rules.md") + testhelpers.MakeFile(t, liveFile, "# rules\n") + + if _, err := ps.ProjectPush(context.Background(), repoDir, false); err != nil { + t.Fatalf("ProjectPush: %v", err) + } + + // Simulate a fresh clone: the live path is a real file tracked upstream. + if err := os.Remove(liveFile); err != nil { + t.Fatalf("remove symlink: %v", err) + } + testhelpers.MakeFile(t, liveFile, "upstream content\n") + if out, err := exec.Command("git", "-C", repoDir, "add", ".cursor/rules.md").CombinedOutput(); err != nil { + t.Fatalf("git add: %v\n%s", err, out) + } + + info, err := ps.ProjectRestore(context.Background(), repoDir, false, false) + if err != nil { + t.Fatalf("ProjectRestore: %v", err) + } + if len(info.Restored) != 0 { + t.Errorf("restored = %v, want empty", info.Restored) + } + if len(info.SkippedTracked) != 1 || info.SkippedTracked[0] != ".cursor/rules.md" { + t.Errorf("skipped = %v, want [.cursor/rules.md]", info.SkippedTracked) + } + if len(info.BackedUp) != 0 { + t.Errorf("backed up = %v, want empty", info.BackedUp) + } + + liveInfo, err := os.Lstat(liveFile) + if err != nil { + t.Fatal(err) + } + if liveInfo.Mode()&os.ModeSymlink != 0 { + t.Error("expected tracked live file to remain a real file") + } + + // Force replaces it with a symlink, backing the real file up. + forced, err := ps.ProjectRestore(context.Background(), repoDir, false, true) + if err != nil { + t.Fatalf("ProjectRestore --force: %v", err) + } + if len(forced.Restored) != 1 || len(forced.BackedUp) != 1 { + t.Errorf("forced restored = %v, backed up = %v", forced.Restored, forced.BackedUp) + } + liveInfo, err = os.Lstat(liveFile) + if err != nil { + t.Fatal(err) + } + if liveInfo.Mode()&os.ModeSymlink == 0 { + t.Error("expected symlink after forced restore") + } +} + func TestProjectRestore_DryRun(t *testing.T) { svc, home := testhelpers.TestHome(t) repoDir := filepath.Join(home, "repos", "hermes") @@ -485,14 +919,14 @@ func TestProjectRestore_DryRun(t *testing.T) { initProjectRepo(t, repoDir) ps := service.NewProjectService(svc) - if _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { t.Fatalf("add pattern: %v", err) } liveFile := filepath.Join(repoDir, ".cursor", "rules.md") testhelpers.MakeFile(t, liveFile, "# rules\n") - if _, err := ps.ProjectPush(context.Background(), repoDir); err != nil { + if _, err := ps.ProjectPush(context.Background(), repoDir, false); err != nil { t.Fatalf("ProjectPush: %v", err) } @@ -500,7 +934,7 @@ func TestProjectRestore_DryRun(t *testing.T) { t.Fatalf("remove symlink: %v", err) } - info, err := ps.ProjectRestore(context.Background(), repoDir, true) + info, err := ps.ProjectRestore(context.Background(), repoDir, true, false) if err != nil { t.Fatalf("ProjectRestore: %v", err) } @@ -531,14 +965,14 @@ func TestProjectPull_PullsAndRestores(t *testing.T) { } ps := service.NewProjectService(svc) - if _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".cursor/**"); err != nil { t.Fatalf("add pattern: %v", err) } liveFile := filepath.Join(repoDir, ".cursor", "rules.md") testhelpers.MakeFile(t, liveFile, "# rules\n") - result, err := ps.ProjectPush(context.Background(), repoDir) + result, err := ps.ProjectPush(context.Background(), repoDir, false) if err != nil { t.Fatalf("ProjectPush: %v", err) } @@ -548,7 +982,7 @@ func TestProjectPull_PullsAndRestores(t *testing.T) { t.Fatalf("remove symlink: %v", err) } - info, err := ps.ProjectPull(context.Background(), repoDir) + info, err := ps.ProjectPull(context.Background(), repoDir, false) if err != nil { t.Fatalf("ProjectPull: %v", err) } diff --git a/service/service.go b/service/service.go index f227fda..b835301 100644 --- a/service/service.go +++ b/service/service.go @@ -76,6 +76,9 @@ type ListResult struct { type RestoreInfo struct { Restored []string BackedUp []string + // SkippedTracked lists project-scope paths left untouched because the + // project's own git index tracks them (requires force to manage). + SkippedTracked []string } // OwnershipCollision describes a path claimed by more than one scope. From dc78999cb55e342bcb0bfa69a7fe5c878d86d3e9 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sun, 23 Aug 2026 11:59:33 -0500 Subject: [PATCH 6/7] feat: added project forget, remove, sync for core lnk alignment --- README.md | 17 +- cmd/root.go | 211 +++++++++++++- service/project.go | 616 +++++++++++++++++++++++++++++++++++++--- service/project_test.go | 484 ++++++++++++++++++++++++++++++- service/service.go | 3 + 5 files changed, 1267 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index d2c514c..801772e 100644 --- a/README.md +++ b/README.md @@ -196,10 +196,14 @@ lnk project add .crush/** # track all files under .crush/ lnk project add .vscode/settings.json # track a single file lnk project list # show effective global + local patterns lnk project push # move matches to lnk storage and symlink back +lnk project sync # reconcile patterns, live files, and storage 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 -lnk project untrack .crush/** # remove a local pattern +lnk project untrack .crush/** # remove a local pattern and restore its files +lnk project untrack --keep .crush/** # remove a pattern but leave files managed +lnk project remove # stop managing the project, restore all files +lnk project forget # stop managing the project, keep stored files ``` 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. @@ -237,10 +241,13 @@ man man/lnk-project-push.1 # read a generated page | `project init` | Activate project scope in the current git repo | | `project add ` | Add patterns to the project's `.lnkinclude` | | `project list` | Show effective project patterns | -| `project untrack ` | Remove a pattern from the project's `.lnkinclude` | -| `project push` | Move matching project files to lnk storage | -| `project restore [--dry-run]` | Recreate project symlinks from storage | -| `project pull` | Pull lnk repo and restore project symlinks | +| `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 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 | +| `project forget` | Stop managing the project but keep stored files | ## Global Options diff --git a/cmd/root.go b/cmd/root.go index e18e992..f9f4042 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -174,8 +174,11 @@ func newProjectCmd(repoFlag *string) *cobra.Command { cmd.AddCommand(newProjectListCmd(repoFlag)) cmd.AddCommand(newProjectUntrackCmd(repoFlag)) cmd.AddCommand(newProjectPushCmd(repoFlag)) + cmd.AddCommand(newProjectSyncCmd(repoFlag)) cmd.AddCommand(newProjectRestoreCmd(repoFlag)) cmd.AddCommand(newProjectPullCmd(repoFlag)) + cmd.AddCommand(newProjectRemoveCmd(repoFlag)) + cmd.AddCommand(newProjectForgetCmd(repoFlag)) return cmd } @@ -316,9 +319,11 @@ func newProjectListCmd(repoFlag *string) *cobra.Command { // newProjectUntrackCmd returns the "project untrack" subcommand. func newProjectUntrackCmd(repoFlag *string) *cobra.Command { + var keep bool + cmd := &cobra.Command{ - Use: "untrack ", - Short: "Remove a pattern from .lnkinclude", + Use: "untrack [--keep] ", + Short: "Remove a pattern from .lnkinclude and unmanage its files", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { projectRoot, err := projectDir(cmd) @@ -327,25 +332,193 @@ func newProjectUntrackCmd(repoFlag *string) *cobra.Command { } ps := service.NewProjectService(svc(repoFlag)) - removed, isGlobal, err := ps.ProjectUntrackPattern(cmd.Context(), projectRoot, args[0]) + result, err := ps.ProjectUntrackPattern(cmd.Context(), projectRoot, args[0], keep) if err != nil { return err } - if removed { - _, err = fmt.Fprintf(cmd.OutOrStdout(), "Removed '%s' from .lnkinclude — remember to commit it.\n", args[0]) - } else if isGlobal { + if result.IsGlobal { app := svc(repoFlag) globalPath := filepath.Join(app.RepoPath(), ".lnkinclude") _, err = fmt.Fprintf(cmd.ErrOrStderr(), "This pattern comes from the global .lnkinclude — edit %s to remove it.\n", globalPath) + return err } - return err + + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Removed '%s' from .lnkinclude — remember to commit it.\n", args[0]); err != nil { + return err + } + if len(result.Released) > 0 { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Restored %d file(s) to the project:\n", len(result.Released)); err != nil { + return err + } + for _, path := range result.Released { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), " %s\n", path); err != nil { + return err + } + } + } + for _, path := range result.BackedUp { + if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "warning: existing file backed up to %s.lnk-backup\n", path); err != nil { + return err + } + } + return nil + }, + } + + cmd.Flags().BoolVar(&keep, "keep", false, "only edit .lnkinclude, leaving managed files in place") + return cmd +} + +// newProjectSyncCmd returns the "project sync" subcommand. +func newProjectSyncCmd(repoFlag *string) *cobra.Command { + var dryRun bool + var pruneDeletions bool + var force bool + + cmd := &cobra.Command{ + Use: "sync [--dry-run] [--prune-deletions] [--force]", + Short: "Reconcile patterns, live files, and project storage", + RunE: func(cmd *cobra.Command, args []string) error { + 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 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(cmd.ErrOrStderr(), "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 }, } + + 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") return cmd } -// newProjectPushCmd returns the "project push" subcommand. +// 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 { + return nil + } + if _, err := fmt.Fprintf(w, "%s %d %s:\n", title, len(paths), suffix); err != nil { + return err + } + for _, path := range paths { + if _, err := fmt.Fprintf(w, " %s\n", path); err != nil { + return err + } + } + return nil +} + +// newProjectRemoveCmd returns the "project remove" subcommand. +func newProjectRemoveCmd(repoFlag *string) *cobra.Command { + return &cobra.Command{ + Use: "remove", + Short: "Stop managing this project: restore its files and delete storage", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + projectRoot, err := projectDir(cmd) + if err != nil { + return err + } + + ps := service.NewProjectService(svc(repoFlag)) + result, err := ps.ProjectRemove(cmd.Context(), projectRoot) + if err != nil { + return err + } + + if err := printRestore(cmd.OutOrStdout(), service.RestoreInfo{Restored: result.Restored, BackedUp: result.BackedUp}, false); err != nil { + return err + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Removed project storage for %s\n", result.ProjectID); err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), ".lnkinclude was left in place; delete it to give up the patterns") + return err + }, + } +} + +// newProjectForgetCmd returns the "project forget" subcommand. +func newProjectForgetCmd(repoFlag *string) *cobra.Command { + return &cobra.Command{ + Use: "forget", + Short: "Stop managing this project but keep its stored files", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + projectRoot, err := projectDir(cmd) + if err != nil { + return err + } + + ps := service.NewProjectService(svc(repoFlag)) + result, err := ps.ProjectForget(cmd.Context(), projectRoot) + if err != nil { + return err + } + + w := cmd.OutOrStdout() + if len(result.Unlinked) == 0 { + _, err = fmt.Fprintln(w, "No managed symlinks found") + } else { + if _, err := fmt.Fprintf(w, "Removed %d symlink(s) from the project:\n", len(result.Unlinked)); err != nil { + return err + } + for _, path := range result.Unlinked { + if _, err := fmt.Fprintf(w, " %s\n", path); err != nil { + return err + } + } + } + if err != nil { + return err + } + _, err = fmt.Fprintln(w, "Stored files kept; run 'lnk project restore' to bring them back") + return err + }, + } +} func newProjectPushCmd(repoFlag *string) *cobra.Command { var force bool @@ -832,16 +1005,28 @@ func printRestore(w io.Writer, info service.RestoreInfo, dryRun bool) error { } } } - if len(info.SkippedTracked) == 0 { + if len(info.SkippedTracked) == 0 && len(info.SkippedUnmatched) == 0 { return nil } - if _, err := fmt.Fprintf(w, "Skipped %d path(s) tracked by the project's git (use --force to manage them)\n", len(info.SkippedTracked)); err != nil { - return err + if len(info.SkippedTracked) > 0 { + if _, err := fmt.Fprintf(w, "Skipped %d path(s) tracked by the project's git (use --force to manage them)\n", len(info.SkippedTracked)); err != nil { + return err + } + for _, path := range info.SkippedTracked { + if _, err := fmt.Fprintf(w, " %s\n", path); err != nil { + return err + } + } } - for _, path := range info.SkippedTracked { - if _, err := fmt.Fprintf(w, " %s\n", path); err != nil { + if len(info.SkippedUnmatched) > 0 { + if _, err := fmt.Fprintf(w, "Skipped %d stored path(s) that no longer match patterns (run 'lnk project sync' to reconcile)\n", len(info.SkippedUnmatched)); err != nil { return err } + for _, path := range info.SkippedUnmatched { + if _, err := fmt.Fprintf(w, " %s\n", path); err != nil { + return err + } + } } return nil } diff --git a/service/project.go b/service/project.go index 668a9a7..9b82741 100644 --- a/service/project.go +++ b/service/project.go @@ -115,40 +115,116 @@ func (ps *ProjectService) ProjectListPatterns(ctx context.Context, projectRoot s return global, local, nil } +// ProjectUntrackResult reports the outcome of ProjectUntrackPattern. +type ProjectUntrackResult struct { + // Removed is true when the pattern was found in (and removed from) the + // local .lnkinclude. + Removed bool + // IsGlobal is true when the pattern only exists in the global + // .lnkinclude, which must be edited directly. + IsGlobal bool + // Released lists stored files moved back to the project because they no + // longer match the effective patterns (empty when keep is set). + Released []string + // BackedUp lists live files renamed to .lnk-backup during release. + BackedUp []string +} + // ProjectUntrackPattern removes a pattern from the project's .lnkinclude at -// the git root. It returns removed=true when the pattern was removed from -// the local file. When the pattern exists only in the global file, it -// returns removed=false and isGlobal=true so the caller can print a warning. -func (ps *ProjectService) ProjectUntrackPattern(ctx context.Context, projectRoot, pattern string) (removed, isGlobal bool, err error) { +// the git root. Unless keep is set, stored files that no longer match the +// effective patterns are moved back to their live paths (mirroring 'lnk +// remove' for host scope) and the storage change is committed. When the +// pattern exists only in the global file, IsGlobal is set so the caller can +// print a warning. +func (ps *ProjectService) ProjectUntrackPattern(ctx context.Context, projectRoot, pattern string, keep bool) (ProjectUntrackResult, error) { root, err := ps.resolveProjectRoot(ctx, projectRoot) if err != nil { - return false, false, err + return ProjectUntrackResult{}, err } localPath := filepath.Join(root, ".lnkinclude") localLines, err := patterns.Load(localPath) if err != nil { - return false, false, fmt.Errorf("load local .lnkinclude: %w", err) + return ProjectUntrackResult{}, fmt.Errorf("load local .lnkinclude: %w", err) } - removed = slices.Contains(localLines, pattern) + // Accept the pattern as written or its normalized form, so 'untrack + // .todo/' matches a '.todo' entry relativized at add time (and vice + // versa for verbatim glob patterns). + target := pattern + if !slices.Contains(localLines, target) { + if normalized, normErr := normalizePattern(root, pattern); normErr == nil && normalized != pattern { + if slices.Contains(localLines, normalized) { + target = normalized + } + } + } - if removed { - if err := rewritePatterns(localPath, localLines, pattern); err != nil { - return false, false, err + if !slices.Contains(localLines, target) { + global, err := patterns.Load(filepath.Join(ps.svc.RepoPath(), ".lnkinclude")) + if err != nil { + return ProjectUntrackResult{}, fmt.Errorf("load global .lnkinclude: %w", err) } - return true, false, nil + if slices.Contains(global, target) { + return ProjectUntrackResult{IsGlobal: true}, nil + } + return ProjectUntrackResult{}, lnkerror.WithPath(lnkerror.ErrNotManaged, pattern) } - global, err := patterns.Load(filepath.Join(ps.svc.RepoPath(), ".lnkinclude")) + if err := rewritePatterns(localPath, localLines, target); err != nil { + return ProjectUntrackResult{}, err + } + result := ProjectUntrackResult{Removed: true} + + if keep { + return result, nil + } + + effective, err := ps.effectivePatterns(root) if err != nil { - return false, false, fmt.Errorf("load global .lnkinclude: %w", err) + return result, err } - if slices.Contains(global, pattern) { - return false, true, nil + released, backedUp, err := ps.releaseUnmatched(ctx, root, effective, false) + if err != nil { + return result, err } + result.Released = released + result.BackedUp = backedUp - return false, false, lnkerror.WithPath(lnkerror.ErrNotManaged, pattern) + if len(released) > 0 { + id, err := ps.projectID(ctx, root) + if err != nil { + return result, err + } + if err := ps.svc.git.AddAll(ctx); err != nil { + return result, err + } + hasChanges, err := ps.svc.git.HasChanges(ctx) + if err != nil { + return result, err + } + if hasChanges { + if err := ps.svc.commit(ctx, "lnk: untracked '"+target+"' in project "+id); err != nil { + return result, err + } + } + } + + return result, nil +} + +// effectivePatterns returns the combined global and local pattern lists. +// Local entries come last so they can negate global ones. +func (ps *ProjectService) effectivePatterns(root string) ([]string, error) { + global, err := patterns.Load(filepath.Join(ps.svc.RepoPath(), ".lnkinclude")) + if err != nil { + return nil, fmt.Errorf("load global .lnkinclude: %w", err) + } + local, err := patterns.Load(filepath.Join(root, ".lnkinclude")) + if err != nil { + return nil, fmt.Errorf("load local .lnkinclude: %w", err) + } + return slices.Concat(global, local), nil } // resolveProjectRoot anchors a project command at the root of the git @@ -441,11 +517,71 @@ func (ps *ProjectService) ProjectPush(ctx context.Context, projectRoot string, f return ProjectPushResult{}, err } - result := ProjectPushResult{ProjectID: id} - fs := &fspkg.FileSystem{} - var failed []error + stats, err := syncNewMatches(&fspkg.FileSystem{}, root, storageDir, effective, tracked, force, false) + if err != nil { + return ProjectPushResult{}, err + } + + result := ProjectPushResult{ + ProjectID: id, + Synced: stats.synced, + SkippedTracked: stats.skippedTracked, + } - err = walkProjectFiles(root, func(path, rel string) error { + if err := ps.svc.git.AddAll(ctx); err != nil { + return result, err + } + + hasChanges, err := ps.svc.git.HasChanges(ctx) + if err != nil { + return result, err + } + if hasChanges { + if err := ps.svc.commit(ctx, "lnk: sync project "+id); err != nil { + return result, err + } + } + + if len(stats.failed) > 0 { + return result, fmt.Errorf("%w: %w", lnkerror.ErrSyncFailed, errors.Join(stats.failed...)) + } + + return result, nil +} + +// moveToStorage moves livePath to storagePath and symlinks it back. If +// linking fails the move is rolled back so the live file is never lost. +func moveToStorage(fs *fspkg.FileSystem, livePath, storagePath string) error { + if err := os.MkdirAll(filepath.Dir(storagePath), 0o755); err != nil { + return fmt.Errorf("create storage directory: %w", err) + } + if err := fs.MoveFile(livePath, storagePath); err != nil { + return fmt.Errorf("move to storage: %w", err) + } + if err := fs.CreateSymlink(storagePath, livePath); err != nil { + if rbErr := fs.MoveFile(storagePath, livePath); rbErr != nil { + return fmt.Errorf("create symlink: %w (rollback failed: %v)", err, rbErr) + } + return fmt.Errorf("create symlink: %w", err) + } + return nil +} + +// matchSyncStats accumulates the outcome of syncNewMatches. +type matchSyncStats struct { + synced []string + skippedTracked []string + failed []error +} + +// syncNewMatches moves live files matching the effective patterns into +// storage and symlinks them back (project reconciliation class 1: newly +// matched files). Files tracked by the project's own git are skipped unless +// force is set. In dryRun mode matches are reported but not moved and move +// failures are collected per file rather than aborting the walk. +func syncNewMatches(fs *fspkg.FileSystem, root, storageDir string, effective []string, tracked map[string]struct{}, force, dryRun bool) (matchSyncStats, error) { + var stats matchSyncStats + err := walkProjectFiles(root, func(path, rel string) error { if implicitlyExcluded(rel) { return nil } @@ -458,58 +594,446 @@ func (ps *ProjectService) ProjectPush(ctx context.Context, projectRoot string, f } if !force { if _, ok := tracked[rel]; ok { - result.SkippedTracked = append(result.SkippedTracked, rel) + stats.skippedTracked = append(stats.skippedTracked, rel) return nil } } + if dryRun { + stats.synced = append(stats.synced, rel) + return nil + } if err := moveToStorage(fs, path, filepath.Join(storageDir, filepath.FromSlash(rel))); err != nil { + stats.failed = append(stats.failed, fmt.Errorf("%s: %w", rel, err)) + return nil + } + stats.synced = append(stats.synced, rel) + return nil + }) + return stats, err +} + +// releaseFile moves a stored file back to its live path. A symlink pointing +// into storageDir is simply removed; any other existing live file (or a +// foreign symlink) is first renamed to .lnk-backup. It reports whether a +// backup was made. +func releaseFile(fs *fspkg.FileSystem, storagePath, livePath, storageDir string) (backedUp bool, err error) { + if liveInfo, statErr := os.Lstat(livePath); statErr == nil { + if liveInfo.Mode()&os.ModeSymlink != 0 && isStorageSymlink(livePath, storageDir) { + if err := os.Remove(livePath); err != nil { + return false, fmt.Errorf("remove managed symlink: %w", err) + } + } else { + backupPath := livePath + ".lnk-backup" + if _, err := os.Lstat(backupPath); err == nil { + return false, lnkerror.WithPath(lnkerror.ErrBackupExists, backupPath) + } + if err := os.Rename(livePath, backupPath); err != nil { + return false, fmt.Errorf("backup existing file %s: %w", livePath, err) + } + backedUp = true + } + } + if err := os.MkdirAll(filepath.Dir(livePath), 0o755); err != nil { + return false, fmt.Errorf("create live parent directory: %w", err) + } + if err := fs.MoveFile(storagePath, livePath); err != nil { + return false, fmt.Errorf("restore live file: %w", err) + } + return backedUp, nil +} + +// isStorageSymlink reports whether livePath is a symlink whose target lies +// inside storageDir. +func isStorageSymlink(livePath, storageDir string) bool { + target, err := os.Readlink(livePath) + if err != nil { + return false + } + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(livePath), target) + } + target = filepath.Clean(target) + rel, err := filepath.Rel(storageDir, target) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// releaseUnmatched moves stored files that no longer match the effective +// patterns back to their live paths (project reconciliation class 2: pattern +// drift). In dryRun mode the affected paths are reported but not moved. +func (ps *ProjectService) releaseUnmatched(ctx context.Context, root string, effective []string, dryRun bool) (released, backedUp []string, err error) { + id, err := ps.projectID(ctx, root) + if err != nil { + return nil, nil, err + } + storageDir := filepath.Join(ps.svc.RepoPath(), "projects", id) + if _, err := os.Stat(storageDir); err != nil { + return nil, nil, nil + } + + fs := &fspkg.FileSystem{} + var failed []error + + err = filepath.Walk(storageDir, func(path string, fi os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if fi.IsDir() { + return nil + } + rel, err := filepath.Rel(storageDir, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + + match, err := patterns.Match(effective, rel) + if err != nil { + return err + } + if match { + return nil + } + + if dryRun { + released = append(released, rel) + return nil + } + backed, err := releaseFile(fs, path, filepath.Join(root, filepath.FromSlash(rel)), storageDir) + if err != nil { failed = append(failed, fmt.Errorf("%s: %w", rel, err)) return nil } - result.Synced = append(result.Synced, rel) + released = append(released, rel) + if backed { + backedUp = append(backedUp, rel) + } + return nil + }) + if err != nil { + return released, backedUp, err + } + + if !dryRun { + if err := fspkg.RemoveEmptyDirs(storageDir); err != nil { + return released, backedUp, fmt.Errorf("prune empty storage directories: %w", err) + } + } + + if len(failed) > 0 { + return released, backedUp, fmt.Errorf("%w: %w", lnkerror.ErrSyncFailed, errors.Join(failed...)) + } + return released, backedUp, nil +} + +// liveDeletions finds stored files that still match the effective patterns +// but whose live paths have gone missing (project reconciliation class 3). +// When prune is set their storage copies are deleted; otherwise they are +// only reported. In dryRun mode nothing is deleted. +func (ps *ProjectService) liveDeletions(ctx context.Context, root string, effective []string, dryRun, prune bool) (deletions, pruned []string, err error) { + id, err := ps.projectID(ctx, root) + if err != nil { + return nil, nil, err + } + storageDir := filepath.Join(ps.svc.RepoPath(), "projects", id) + if _, err := os.Stat(storageDir); err != nil { + return nil, nil, nil + } + + err = filepath.Walk(storageDir, func(path string, fi os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if fi.IsDir() { + return nil + } + rel, err := filepath.Rel(storageDir, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + + match, err := patterns.Match(effective, rel) + if err != nil { + return err + } + if !match { + return nil + } + + if _, err := os.Lstat(filepath.Join(root, filepath.FromSlash(rel))); err == nil { + return nil + } + + if prune { + pruned = append(pruned, rel) + if !dryRun { + if err := os.Remove(path); err != nil { + return fmt.Errorf("prune stored file %s: %w", rel, err) + } + } + return nil + } + deletions = append(deletions, rel) return nil }) + if err != nil { + return deletions, pruned, err + } + + if prune && !dryRun { + if err := fspkg.RemoveEmptyDirs(storageDir); err != nil { + return deletions, pruned, fmt.Errorf("prune empty storage directories: %w", err) + } + } + return deletions, pruned, nil +} + +// ProjectSyncResult reports the reconciliation outcome of ProjectSync. +type ProjectSyncResult struct { + ProjectID string + // Synced lists live files newly moved to storage and symlinked back. + Synced []string + // Released lists stored files moved back to the project because their + // patterns no longer match. + Released []string + // BackedUp lists live files renamed to .lnk-backup during release. + BackedUp []string + // Deletions lists stored files whose live copies are gone (kept unless + // pruneDeletions is set). + Deletions []string + // Pruned lists stored files deleted because their live copies are gone. + Pruned []string + // SkippedTracked lists matched files left untouched because the + // project's own git index tracks them. + SkippedTracked []string +} + +// ProjectSync reconciles live files, stored files, and the effective +// patterns in both directions: newly matched files are pushed to storage, +// files whose patterns no longer match are moved back to the project, and +// stored files whose live copies were deleted are reported (or pruned with +// pruneDeletions). Storage changes are staged and committed in the lnk repo. +func (ps *ProjectService) ProjectSync(ctx context.Context, projectRoot string, dryRun, pruneDeletions, force bool) (ProjectSyncResult, error) { + root, err := ps.resolveProjectRoot(ctx, projectRoot) + if err != nil { + return ProjectSyncResult{}, err + } + + id, err := ps.projectID(ctx, root) + if err != nil { + return ProjectSyncResult{}, err + } + + storageDir := filepath.Join(ps.svc.RepoPath(), "projects", id) + if !dryRun { + if err := os.MkdirAll(storageDir, 0o755); err != nil { + return ProjectSyncResult{}, fmt.Errorf("create project storage: %w", err) + } + } + + effective, err := ps.effectivePatterns(root) + if err != nil { + return ProjectSyncResult{}, err + } + + tracked, err := projectTrackedFiles(ctx, root) + if err != nil { + return ProjectSyncResult{}, err + } + + result := ProjectSyncResult{ProjectID: id} + + stats, err := syncNewMatches(&fspkg.FileSystem{}, root, storageDir, effective, tracked, force, dryRun) if err != nil { return result, err } + result.Synced = stats.synced + result.SkippedTracked = stats.skippedTracked - if err := ps.svc.git.AddAll(ctx); err != nil { + result.Released, result.BackedUp, err = ps.releaseUnmatched(ctx, root, effective, dryRun) + if err != nil { return result, err } - hasChanges, err := ps.svc.git.HasChanges(ctx) + result.Deletions, result.Pruned, err = ps.liveDeletions(ctx, root, effective, dryRun, pruneDeletions) if err != nil { return result, err } - if hasChanges { - if err := ps.svc.commit(ctx, "lnk: sync project "+id); err != nil { + + if !dryRun { + if err := ps.svc.git.AddAll(ctx); err != nil { return result, err } + hasChanges, err := ps.svc.git.HasChanges(ctx) + if err != nil { + return result, err + } + if hasChanges { + if err := ps.svc.commit(ctx, "lnk: sync project "+id); err != nil { + return result, err + } + } + } + + if len(stats.failed) > 0 { + return result, fmt.Errorf("%w: %w", lnkerror.ErrSyncFailed, errors.Join(stats.failed...)) + } + + return result, nil +} + +// ProjectRemoveResult reports the outcome of ProjectRemove. +type ProjectRemoveResult struct { + ProjectID string + // Restored lists files moved back from storage to their live paths. + Restored []string + // BackedUp lists live files renamed to .lnk-backup during the move-back. + BackedUp []string +} + +// ProjectRemove stops managing the whole project: every stored file is moved +// back to its live path (existing live files are backed up first), the +// project's storage directory is deleted, and the removal is committed in +// the lnk repo. The project's .lnkinclude is left in place so the project +// can be re-adopted later with 'lnk project push'. +func (ps *ProjectService) ProjectRemove(ctx context.Context, projectRoot string) (ProjectRemoveResult, error) { + root, err := ps.resolveProjectRoot(ctx, projectRoot) + if err != nil { + return ProjectRemoveResult{}, err + } + + id, err := ps.projectID(ctx, root) + if err != nil { + return ProjectRemoveResult{}, err } + storageDir := filepath.Join(ps.svc.RepoPath(), "projects", id) + if _, err := os.Stat(storageDir); err != nil { + return ProjectRemoveResult{}, lnkerror.WithPathAndSuggestion(lnkerror.ErrNotManaged, root, "no stored files for this project") + } + + result := ProjectRemoveResult{ProjectID: id} + fs := &fspkg.FileSystem{} + var failed []error + + err = filepath.Walk(storageDir, func(path string, fi os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if fi.IsDir() { + return nil + } + rel, err := filepath.Rel(storageDir, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + + backed, err := releaseFile(fs, path, filepath.Join(root, filepath.FromSlash(rel)), storageDir) + if err != nil { + failed = append(failed, fmt.Errorf("%s: %w", rel, err)) + return nil + } + result.Restored = append(result.Restored, rel) + if backed { + result.BackedUp = append(result.BackedUp, rel) + } + return nil + }) + if err != nil { + return result, err + } + + // Keep the storage directory (and skip the commit) when some files could + // not be moved back, so nothing is lost. if len(failed) > 0 { return result, fmt.Errorf("%w: %w", lnkerror.ErrSyncFailed, errors.Join(failed...)) } + if err := os.RemoveAll(storageDir); err != nil { + return result, fmt.Errorf("remove project storage: %w", err) + } + + if err := ps.svc.git.AddAll(ctx); err != nil { + return result, err + } + hasChanges, err := ps.svc.git.HasChanges(ctx) + if err != nil { + return result, err + } + if hasChanges { + if err := ps.svc.commit(ctx, "lnk: removed project "+id); err != nil { + return result, err + } + } + return result, nil } -// moveToStorage moves livePath to storagePath and symlinks it back. If -// linking fails the move is rolled back so the live file is never lost. -func moveToStorage(fs *fspkg.FileSystem, livePath, storagePath string) error { - if err := os.MkdirAll(filepath.Dir(storagePath), 0o755); err != nil { - return fmt.Errorf("create storage directory: %w", err) +// ProjectForgetResult reports the outcome of ProjectForget. +type ProjectForgetResult struct { + ProjectID string + // Unlinked lists live symlinks pointing into project storage that were + // removed. + Unlinked []string +} + +// ProjectForget stops managing the whole project but keeps its stored files: +// live symlinks pointing into the project's storage are removed while the +// storage copy (and .lnkinclude) stay in place, so 'lnk project restore' can +// bring the files back later. Live real files are never touched. +func (ps *ProjectService) ProjectForget(ctx context.Context, projectRoot string) (ProjectForgetResult, error) { + root, err := ps.resolveProjectRoot(ctx, projectRoot) + if err != nil { + return ProjectForgetResult{}, err } - if err := fs.MoveFile(livePath, storagePath); err != nil { - return fmt.Errorf("move to storage: %w", err) + + id, err := ps.projectID(ctx, root) + if err != nil { + return ProjectForgetResult{}, err } - if err := fs.CreateSymlink(storagePath, livePath); err != nil { - if rbErr := fs.MoveFile(storagePath, livePath); rbErr != nil { - return fmt.Errorf("create symlink: %w (rollback failed: %v)", err, rbErr) + + storageDir := filepath.Join(ps.svc.RepoPath(), "projects", id) + if _, err := os.Stat(storageDir); err != nil { + return ProjectForgetResult{}, lnkerror.WithPathAndSuggestion(lnkerror.ErrNotManaged, root, "no stored files for this project") + } + + result := ProjectForgetResult{ProjectID: id} + + err = filepath.Walk(storageDir, func(path string, fi os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr } - return fmt.Errorf("create symlink: %w", err) + if fi.IsDir() { + return nil + } + rel, err := filepath.Rel(storageDir, path) + if err != nil { + return err + } + + livePath := filepath.Join(root, filepath.FromSlash(filepath.ToSlash(rel))) + liveInfo, statErr := os.Lstat(livePath) + if statErr != nil || liveInfo.Mode()&os.ModeSymlink == 0 { + return nil + } + if !isStorageSymlink(livePath, storageDir) { + return nil + } + if err := os.Remove(livePath); err != nil { + return fmt.Errorf("remove managed symlink %s: %w", livePath, err) + } + result.Unlinked = append(result.Unlinked, filepath.ToSlash(rel)) + return nil + }) + if err != nil { + return result, err } - return nil + + return result, nil } // ProjectRestore recreates symlinks for all project-scoped files from @@ -538,6 +1062,11 @@ func (ps *ProjectService) ProjectRestore(ctx context.Context, projectRoot string return RestoreInfo{}, err } + effective, err := ps.effectivePatterns(root) + if err != nil { + return RestoreInfo{}, err + } + r := &scope.ProjectRootResolver{ GitRoot: root, StorageDir: storageDir, @@ -560,6 +1089,17 @@ func (ps *ProjectService) ProjectRestore(ctx context.Context, projectRoot string } rel = filepath.ToSlash(rel) + // Stored files whose patterns no longer match are drift, not state to + // recreate; 'lnk project sync' moves them back. + match, err := patterns.Match(effective, rel) + if err != nil { + return err + } + if !match { + info.SkippedUnmatched = append(info.SkippedUnmatched, rel) + return nil + } + livePath, err := r.ToLive(rel) if err != nil { return err diff --git a/service/project_test.go b/service/project_test.go index 0d9d2c1..896c7f0 100644 --- a/service/project_test.go +++ b/service/project_test.go @@ -139,12 +139,21 @@ func TestProjectCommands_RefuseLnkRepository(t *testing.T) { if _, _, err := ps.ProjectListPatterns(context.Background(), dir); !errors.Is(err, lnkerror.ErrIsLnkRepository) { t.Errorf("ProjectListPatterns(%s) error = %v, want %v", dir, err, lnkerror.ErrIsLnkRepository) } - if _, _, err := ps.ProjectUntrackPattern(context.Background(), dir, "x"); !errors.Is(err, lnkerror.ErrIsLnkRepository) { + if _, err := ps.ProjectUntrackPattern(context.Background(), dir, "x", true); !errors.Is(err, lnkerror.ErrIsLnkRepository) { t.Errorf("ProjectUntrackPattern(%s) error = %v, want %v", dir, err, lnkerror.ErrIsLnkRepository) } if _, err := ps.ProjectRestore(context.Background(), dir, false, false); !errors.Is(err, lnkerror.ErrIsLnkRepository) { t.Errorf("ProjectRestore(%s) error = %v, want %v", dir, err, lnkerror.ErrIsLnkRepository) } + if _, err := ps.ProjectSync(context.Background(), dir, false, false, false); !errors.Is(err, lnkerror.ErrIsLnkRepository) { + t.Errorf("ProjectSync(%s) error = %v, want %v", dir, err, lnkerror.ErrIsLnkRepository) + } + if _, err := ps.ProjectRemove(context.Background(), dir); !errors.Is(err, lnkerror.ErrIsLnkRepository) { + t.Errorf("ProjectRemove(%s) error = %v, want %v", dir, err, lnkerror.ErrIsLnkRepository) + } + if _, err := ps.ProjectForget(context.Background(), dir); !errors.Is(err, lnkerror.ErrIsLnkRepository) { + t.Errorf("ProjectForget(%s) error = %v, want %v", dir, err, lnkerror.ErrIsLnkRepository) + } } } @@ -400,14 +409,14 @@ func TestProjectUntrackPattern_RemovesLocal(t *testing.T) { t.Fatalf("add: %v", err) } - removed, isGlobal, err := ps.ProjectUntrackPattern(context.Background(), repoDir, "agents.md") + result, err := ps.ProjectUntrackPattern(context.Background(), repoDir, "agents.md", true) if err != nil { t.Fatalf("ProjectUntrackPattern: %v", err) } - if !removed { + if !result.Removed { t.Error("expected removed=true") } - if isGlobal { + if result.IsGlobal { t.Error("expected isGlobal=false") } } @@ -424,14 +433,14 @@ func TestProjectUntrackPattern_WarnsForGlobal(t *testing.T) { } ps := service.NewProjectService(svc) - removed, isGlobal, err := ps.ProjectUntrackPattern(context.Background(), repoDir, ".todo/**") + result, err := ps.ProjectUntrackPattern(context.Background(), repoDir, ".todo/**", true) if err != nil { t.Fatalf("ProjectUntrackPattern: %v", err) } - if removed { + if result.Removed { t.Error("expected removed=false") } - if !isGlobal { + if !result.IsGlobal { t.Error("expected isGlobal=true") } } @@ -443,7 +452,7 @@ func TestProjectUntrackPattern_ErrorsWhenMissing(t *testing.T) { initProjectRepo(t, repoDir) ps := service.NewProjectService(svc) - _, _, err := ps.ProjectUntrackPattern(context.Background(), repoDir, "missing.md") + _, err := ps.ProjectUntrackPattern(context.Background(), repoDir, "missing.md", true) if err == nil { t.Fatal("expected error for missing pattern") } @@ -991,3 +1000,462 @@ func TestProjectPull_PullsAndRestores(t *testing.T) { } testhelpers.AssertSymlink(t, liveFile, storageFile) } + +// ---------- Lifecycle and reconciliation ---------- + +// newPushedProject creates a project repo with an origin remote, adds the +// pattern, creates the given files (slash-separated, root-relative), and +// pushes. It returns the lnk service, project service, repo dir, and +// project id. +func newPushedProject(t *testing.T, pattern string, files ...string) (*service.Service, *service.ProjectService, string, string) { + t.Helper() + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, pattern); err != nil { + t.Fatalf("add pattern: %v", err) + } + for _, rel := range files { + testhelpers.MakeFile(t, filepath.Join(repoDir, filepath.FromSlash(rel)), "content of "+rel+"\n") + } + result, err := ps.ProjectPush(context.Background(), repoDir, false) + if err != nil { + t.Fatalf("ProjectPush: %v", err) + } + return svc, ps, repoDir, result.ProjectID +} + +func assertRealFile(t *testing.T, path, wantContent string) { + t.Helper() + info, err := os.Lstat(path) + if err != nil { + t.Fatalf("expected real file at %s: %v", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Errorf("expected %s to be a real file, got symlink", path) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != wantContent { + t.Errorf("content of %s = %q, want %q", path, data, wantContent) + } +} + +func TestProjectUntrackPattern_ReleasesNowUnmatchedFiles(t *testing.T) { + svc, ps, repoDir, id := newPushedProject(t, ".todo/**", ".todo/a.md", ".todo/b.md") + storageDir := filepath.Join(svc.RepoPath(), "projects", id) + + result, err := ps.ProjectUntrackPattern(context.Background(), repoDir, ".todo/**", false) + if err != nil { + t.Fatalf("ProjectUntrackPattern: %v", err) + } + if !result.Removed { + t.Error("expected removed=true") + } + if len(result.Released) != 2 { + t.Errorf("released = %v, want 2 entries", result.Released) + } + + assertRealFile(t, filepath.Join(repoDir, ".todo", "a.md"), "content of .todo/a.md\n") + assertRealFile(t, filepath.Join(repoDir, ".todo", "b.md"), "content of .todo/b.md\n") + if testhelpers.FileExists(t, filepath.Join(storageDir, ".todo")) { + t.Error("expected .todo storage to be pruned") + } +} + +func TestProjectUntrackPattern_KeepLeavesFilesManaged(t *testing.T) { + svc, ps, repoDir, id := newPushedProject(t, ".todo/**", ".todo/a.md") + storageFile := filepath.Join(svc.RepoPath(), "projects", id, ".todo", "a.md") + liveFile := filepath.Join(repoDir, ".todo", "a.md") + + result, err := ps.ProjectUntrackPattern(context.Background(), repoDir, ".todo/**", true) + if err != nil { + t.Fatalf("ProjectUntrackPattern: %v", err) + } + if !result.Removed { + t.Error("expected removed=true") + } + if len(result.Released) != 0 { + t.Errorf("released = %v, want empty with keep", result.Released) + } + testhelpers.AssertSymlink(t, liveFile, storageFile) + if !testhelpers.FileExists(t, storageFile) { + t.Error("expected storage copy to remain") + } +} + +func TestProjectUntrackPattern_KeepsStillMatchedFiles(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + for _, pattern := range []string{"*.md", "notes.md"} { + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, pattern); err != nil { + t.Fatalf("add %s: %v", pattern, err) + } + } + testhelpers.MakeFile(t, filepath.Join(repoDir, "notes.md"), "notes\n") + testhelpers.MakeFile(t, filepath.Join(repoDir, "other.md"), "other\n") + result, err := ps.ProjectPush(context.Background(), repoDir, false) + if err != nil { + t.Fatalf("ProjectPush: %v", err) + } + storageDir := filepath.Join(svc.RepoPath(), "projects", result.ProjectID) + + untrack, err := ps.ProjectUntrackPattern(context.Background(), repoDir, "*.md", false) + if err != nil { + t.Fatalf("ProjectUntrackPattern: %v", err) + } + if len(untrack.Released) != 1 || untrack.Released[0] != "other.md" { + t.Errorf("released = %v, want [other.md]", untrack.Released) + } + + // notes.md is still matched by its dedicated pattern. + testhelpers.AssertSymlink(t, filepath.Join(repoDir, "notes.md"), filepath.Join(storageDir, "notes.md")) + assertRealFile(t, filepath.Join(repoDir, "other.md"), "other\n") +} + +func TestProjectSync_PatternDrift(t *testing.T) { + svc, ps, repoDir, id := newPushedProject(t, "**/*.md", ".todo/a.md", "notes.md") + storageDir := filepath.Join(svc.RepoPath(), "projects", id) + + // Simulate a hand-edited manifest that drops the .todo pattern. + manifest := filepath.Join(repoDir, ".lnkinclude") + if err := os.WriteFile(manifest, []byte("notes.md\n"), 0o644); err != nil { + t.Fatal(err) + } + + result, err := ps.ProjectSync(context.Background(), repoDir, false, false, false) + if err != nil { + t.Fatalf("ProjectSync: %v", err) + } + if len(result.Released) != 1 || result.Released[0] != ".todo/a.md" { + t.Errorf("released = %v, want [.todo/a.md]", result.Released) + } + if len(result.Synced) != 0 { + t.Errorf("synced = %v, want empty", result.Synced) + } + if len(result.Deletions) != 0 { + t.Errorf("deletions = %v, want empty", result.Deletions) + } + + assertRealFile(t, filepath.Join(repoDir, ".todo", "a.md"), "content of .todo/a.md\n") + if testhelpers.FileExists(t, filepath.Join(storageDir, ".todo")) { + t.Error("expected .todo storage to be pruned") + } + // The still-matched file remains managed. + testhelpers.AssertSymlink(t, filepath.Join(repoDir, "notes.md"), filepath.Join(storageDir, "notes.md")) +} + +func TestProjectSync_NewMatches(t *testing.T) { + svc, ps, repoDir, id := newPushedProject(t, ".todo/**", ".todo/a.md") + + newFile := filepath.Join(repoDir, ".todo", "b.md") + testhelpers.MakeFile(t, newFile, "b\n") + + result, err := ps.ProjectSync(context.Background(), repoDir, false, false, false) + if err != nil { + t.Fatalf("ProjectSync: %v", err) + } + if len(result.Synced) != 1 || result.Synced[0] != ".todo/b.md" { + t.Errorf("synced = %v, want [.todo/b.md]", result.Synced) + } + testhelpers.AssertSymlink(t, newFile, filepath.Join(svc.RepoPath(), "projects", id, ".todo", "b.md")) +} + +func TestProjectSync_LiveDeletionsReportedThenPruned(t *testing.T) { + svc, ps, repoDir, id := newPushedProject(t, ".todo/**", ".todo/a.md", ".todo/b.md") + storageFile := filepath.Join(svc.RepoPath(), "projects", id, ".todo", "a.md") + + // The user deletes the live symlink. + if err := os.Remove(filepath.Join(repoDir, ".todo", "a.md")); err != nil { + t.Fatal(err) + } + + result, err := ps.ProjectSync(context.Background(), repoDir, false, false, false) + if err != nil { + t.Fatalf("ProjectSync: %v", err) + } + if len(result.Deletions) != 1 || result.Deletions[0] != ".todo/a.md" { + t.Errorf("deletions = %v, want [.todo/a.md]", result.Deletions) + } + if len(result.Pruned) != 0 { + t.Errorf("pruned = %v, want empty without --prune-deletions", result.Pruned) + } + if !testhelpers.FileExists(t, storageFile) { + t.Error("expected storage copy to be kept") + } + + pruned, err := ps.ProjectSync(context.Background(), repoDir, false, true, false) + if err != nil { + t.Fatalf("ProjectSync --prune-deletions: %v", err) + } + if len(pruned.Pruned) != 1 || pruned.Pruned[0] != ".todo/a.md" { + t.Errorf("pruned = %v, want [.todo/a.md]", pruned.Pruned) + } + if testhelpers.FileExists(t, storageFile) { + t.Error("expected storage copy to be pruned") + } +} + +func TestProjectSync_DryRun(t *testing.T) { + svc, ps, repoDir, id := newPushedProject(t, ".todo/**", ".todo/a.md") + storageFile := filepath.Join(svc.RepoPath(), "projects", id, ".todo", "a.md") + liveFile := filepath.Join(repoDir, ".todo", "a.md") + + manifest := filepath.Join(repoDir, ".lnkinclude") + if err := os.WriteFile(manifest, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + before := len(testhelpers.GitLog(t, svc.RepoPath())) + + result, err := ps.ProjectSync(context.Background(), repoDir, true, false, false) + if err != nil { + t.Fatalf("ProjectSync --dry-run: %v", err) + } + if len(result.Released) != 1 || result.Released[0] != ".todo/a.md" { + t.Errorf("released = %v, want [.todo/a.md]", result.Released) + } + + testhelpers.AssertSymlink(t, liveFile, storageFile) + if !testhelpers.FileExists(t, storageFile) { + t.Error("expected storage copy untouched in dry-run") + } + 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 TestProjectSync_SkipsProjectGitTrackedUnlessForced(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + testhelpers.MakeFile(t, filepath.Join(repoDir, "tracked.md"), "tracked\n") + if out, err := exec.Command("git", "-C", repoDir, "add", "tracked.md").CombinedOutput(); err != nil { + t.Fatalf("git add: %v\n%s", err, out) + } + if err := os.WriteFile(filepath.Join(repoDir, ".lnkinclude"), []byte("*.md\n"), 0o644); err != nil { + t.Fatal(err) + } + + ps := service.NewProjectService(svc) + result, err := ps.ProjectSync(context.Background(), repoDir, false, false, false) + if err != nil { + t.Fatalf("ProjectSync: %v", err) + } + if len(result.Synced) != 0 { + t.Errorf("synced = %v, want empty", result.Synced) + } + // Both the fixture README.md and tracked.md are git-tracked. + if len(result.SkippedTracked) != 2 { + t.Errorf("skipped = %v, want 2 entries", result.SkippedTracked) + } + + forced, err := ps.ProjectSync(context.Background(), repoDir, false, false, true) + if err != nil { + t.Fatalf("ProjectSync --force: %v", err) + } + if len(forced.Synced) != 2 { + t.Errorf("forced synced = %v, want 2 entries", forced.Synced) + } +} + +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) + + result, err := ps.ProjectRemove(context.Background(), repoDir) + if err != nil { + t.Fatalf("ProjectRemove: %v", err) + } + if len(result.Restored) != 2 { + t.Errorf("restored = %v, want 2 entries", result.Restored) + } + + assertRealFile(t, filepath.Join(repoDir, ".cursor", "rules.md"), "content of .cursor/rules.md\n") + assertRealFile(t, filepath.Join(repoDir, ".cursor", "extra.md"), "content of .cursor/extra.md\n") + if testhelpers.FileExists(t, storageDir) { + t.Error("expected project storage to be deleted") + } + + // .lnkinclude stays for later re-adoption. + if !testhelpers.FileExists(t, filepath.Join(repoDir, ".lnkinclude")) { + t.Error("expected .lnkinclude to be left in place") + } + + logs := testhelpers.GitLog(t, svc.RepoPath()) + found := false + for _, msg := range logs { + if strings.Contains(msg, "lnk: removed project") { + found = true + } + } + if !found { + t.Errorf("expected a removal commit, got %v", logs) + } +} + +func TestProjectRemove_BacksUpConflictingLiveFile(t *testing.T) { + _, ps, repoDir, _ := newPushedProject(t, ".cursor/**", ".cursor/rules.md") + + liveFile := filepath.Join(repoDir, ".cursor", "rules.md") + if err := os.Remove(liveFile); err != nil { + t.Fatal(err) + } + testhelpers.MakeFile(t, liveFile, "local edits\n") + + result, err := ps.ProjectRemove(context.Background(), repoDir) + if err != nil { + t.Fatalf("ProjectRemove: %v", err) + } + if len(result.BackedUp) != 1 || result.BackedUp[0] != ".cursor/rules.md" { + t.Errorf("backed up = %v, want [.cursor/rules.md]", result.BackedUp) + } + + assertRealFile(t, liveFile, "content of .cursor/rules.md\n") + data, err := os.ReadFile(liveFile + ".lnk-backup") + if err != nil { + t.Fatal(err) + } + if string(data) != "local edits\n" { + t.Errorf("backup content = %q, want local edits", data) + } +} + +func TestProjectRemove_ErrorsWhenNotManaged(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + _, err := ps.ProjectRemove(context.Background(), repoDir) + if !errors.Is(err, lnkerror.ErrNotManaged) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrNotManaged) + } +} + +func TestProjectForget_RemovesSymlinksKeepsStorage(t *testing.T) { + svc, ps, repoDir, id := newPushedProject(t, ".cursor/**", ".cursor/rules.md", ".cursor/extra.md") + storageFile := filepath.Join(svc.RepoPath(), "projects", id, ".cursor", "rules.md") + + result, err := ps.ProjectForget(context.Background(), repoDir) + if err != nil { + t.Fatalf("ProjectForget: %v", err) + } + if len(result.Unlinked) != 2 { + t.Errorf("unlinked = %v, want 2 entries", result.Unlinked) + } + + for _, rel := range []string{"rules.md", "extra.md"} { + if _, err := os.Lstat(filepath.Join(repoDir, ".cursor", rel)); err == nil { + t.Errorf("expected live path %s to be gone", rel) + } + } + if !testhelpers.FileExists(t, storageFile) { + t.Error("expected storage copy to be kept") + } + + // The files can be brought back later. + info, err := ps.ProjectRestore(context.Background(), repoDir, false, false) + if err != nil { + t.Fatalf("ProjectRestore after forget: %v", err) + } + if len(info.Restored) != 2 { + t.Errorf("restored = %v, want 2 entries", info.Restored) + } + testhelpers.AssertSymlink(t, filepath.Join(repoDir, ".cursor", "rules.md"), storageFile) +} + +func TestProjectForget_LeavesForeignSymlinks(t *testing.T) { + svc, ps, repoDir, id := newPushedProject(t, ".cursor/**", ".cursor/rules.md") + storageFile := filepath.Join(svc.RepoPath(), "projects", id, ".cursor", "rules.md") + + liveFile := filepath.Join(repoDir, ".cursor", "rules.md") + if err := os.Remove(liveFile); err != nil { + t.Fatal(err) + } + foreign := filepath.Join(t.TempDir(), "elsewhere") + testhelpers.MakeFile(t, foreign, "not lnk\n") + if err := os.Symlink(foreign, liveFile); err != nil { + t.Fatal(err) + } + + result, err := ps.ProjectForget(context.Background(), repoDir) + if err != nil { + t.Fatalf("ProjectForget: %v", err) + } + if len(result.Unlinked) != 0 { + t.Errorf("unlinked = %v, want empty for a foreign symlink", result.Unlinked) + } + + target, err := os.Readlink(liveFile) + if err != nil { + t.Fatalf("expected foreign symlink to remain: %v", err) + } + if target != foreign { + t.Errorf("symlink target = %q, want %q", target, foreign) + } + if !testhelpers.FileExists(t, storageFile) { + t.Error("expected storage copy to be kept") + } +} + +func TestProjectForget_ErrorsWhenNotManaged(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + _, err := ps.ProjectForget(context.Background(), repoDir) + if !errors.Is(err, lnkerror.ErrNotManaged) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrNotManaged) + } +} + +func TestProjectRestore_GatesOnPatterns(t *testing.T) { + svc, ps, repoDir, id := newPushedProject(t, "**/*.md", ".todo/a.md", "notes.md") + storageDir := filepath.Join(svc.RepoPath(), "projects", id) + + // Hand-edit the manifest to drop the .todo pattern, leaving drift. + manifest := filepath.Join(repoDir, ".lnkinclude") + if err := os.WriteFile(manifest, []byte("notes.md\n"), 0o644); err != nil { + t.Fatal(err) + } + + todoLive := filepath.Join(repoDir, ".todo", "a.md") + notesLive := filepath.Join(repoDir, "notes.md") + if err := os.Remove(todoLive); err != nil { + t.Fatal(err) + } + if err := os.Remove(notesLive); err != nil { + t.Fatal(err) + } + + info, err := ps.ProjectRestore(context.Background(), repoDir, false, false) + if err != nil { + t.Fatalf("ProjectRestore: %v", err) + } + if len(info.Restored) != 1 || info.Restored[0] != "notes.md" { + t.Errorf("restored = %v, want [notes.md]", info.Restored) + } + if len(info.SkippedUnmatched) != 1 || info.SkippedUnmatched[0] != ".todo/a.md" { + t.Errorf("skipped unmatched = %v, want [.todo/a.md]", info.SkippedUnmatched) + } + + if _, err := os.Lstat(todoLive); err == nil { + t.Error("expected unmatched stored file to stay unrestored") + } + testhelpers.AssertSymlink(t, notesLive, filepath.Join(storageDir, "notes.md")) +} diff --git a/service/service.go b/service/service.go index b835301..95eddad 100644 --- a/service/service.go +++ b/service/service.go @@ -79,6 +79,9 @@ type RestoreInfo struct { // SkippedTracked lists project-scope paths left untouched because the // project's own git index tracks them (requires force to manage). SkippedTracked []string + // SkippedUnmatched lists stored project-scope paths whose patterns no + // longer match (run 'lnk project sync' to reconcile). + SkippedUnmatched []string } // OwnershipCollision describes a path claimed by more than one scope. From 09d7c8355341855b78bff26ba411d97f66dc6708 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sun, 23 Aug 2026 12:31:54 -0500 Subject: [PATCH 7/7] feat: global flag, list and doctor integration --- README.md | 17 ++- cmd/root.go | 111 ++++++++++++++-- service/doctor.go | 24 ++++ service/project.go | 275 +++++++++++++++++++++++++++++++++++++++- service/project_test.go | 216 +++++++++++++++++++++++++++++++ 5 files changed, 631 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 801772e..140709e 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ lnk bootstrap # run manually Track project-local configuration files without committing them to the project's own git repository. Useful for `.crush/crush.json`, `.vscode/settings.json`, repo-specific shell aliases, or any file you want backed up in your dotfiles repo but not pushed upstream. -Project scope uses a `.lnkinclude` file inside the project root. Patterns follow `.gitignore` syntax, but a match means "include". Global patterns can live in your lnk repo root; local patterns are project-specific. +Project scope uses a `.lnkinclude` file inside the project root. Patterns follow `.gitignore` syntax, but a match means "include". Global patterns live in your lnk repo root (`.config/lnk/.lnkinclude`) and apply to every project; local patterns are project-specific and are evaluated after the global ones, so they can negate a global include with `!`. ```bash # inside a git repository @@ -195,8 +195,10 @@ lnk project init # create an empty .lnkinclude lnk project add .crush/** # track all files under .crush/ lnk project add .vscode/settings.json # track a single file lnk project list # show effective global + local patterns +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 --prune-deletions # also drop storage for files deleted locally 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 @@ -204,10 +206,23 @@ lnk project untrack .crush/** # remove a local pattern and restore i lnk project untrack --keep .crush/** # remove a pattern but leave files managed lnk project remove # stop managing the project, restore all files lnk project forget # stop managing the project, keep stored files + +# 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 +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. +### 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. +- **Files are tracked individually**, not as directory symlinks. A `.todo/` pattern matches every file under it, so new files are picked up by the next `project push`/`project sync`. This differs from `lnk add`, which symlinks a whole directory as one unit. +- **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`. + ## Man pages Man pages are generated from the Cobra command tree and ship with release archives. diff --git a/cmd/root.go b/cmd/root.go index f9f4042..d62b97f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -231,17 +231,33 @@ func newProjectInitCmd(repoFlag *string) *cobra.Command { // newProjectAddCmd returns the "project add" subcommand. func newProjectAddCmd(repoFlag *string) *cobra.Command { + var global bool + cmd := &cobra.Command{ - Use: "add ", - Short: "Add a pattern to .lnkinclude for the current project", + Use: "add [--global] ", + Short: "Add a pattern to .lnkinclude (local unless --global)", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + ps := service.NewProjectService(svc(repoFlag)) + + if global { + for _, pattern := range args { + normalized, err := ps.ProjectAddGlobalPattern(pattern) + if err != nil { + return err + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Added '%s' to the global .lnkinclude — remember to commit it.\n", normalized); err != nil { + return err + } + } + return nil + } + projectRoot, err := projectDir(cmd) if err != nil { return err } - ps := service.NewProjectService(svc(repoFlag)) for _, pattern := range args { normalized, matched, err := ps.ProjectAddPattern(cmd.Context(), projectRoot, pattern) if err != nil { @@ -259,21 +275,43 @@ func newProjectAddCmd(repoFlag *string) *cobra.Command { return nil }, } + + cmd.Flags().BoolVar(&global, "global", false, "add the pattern to the lnk repo's global .lnkinclude") return cmd } // newProjectListCmd returns the "project list" subcommand. func newProjectListCmd(repoFlag *string) *cobra.Command { + var all bool + cmd := &cobra.Command{ - Use: "list", - Short: "List effective patterns for the current project", + Use: "list [--all]", + Short: "List effective patterns, or all stored projects with --all", RunE: func(cmd *cobra.Command, args []string) error { + ps := service.NewProjectService(svc(repoFlag)) + + if all { + projects, err := ps.ProjectListProjects() + if err != nil { + return err + } + if len(projects) == 0 { + _, err = fmt.Fprintln(cmd.OutOrStdout(), "No stored projects") + return err + } + for _, p := range projects { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s (%d file(s))\n", p.ID, p.Files); err != nil { + return err + } + } + return nil + } + projectRoot, err := projectDir(cmd) if err != nil { return err } - ps := service.NewProjectService(svc(repoFlag)) global, local, err := ps.ProjectListPatterns(cmd.Context(), projectRoot) if err != nil { return err @@ -314,24 +352,36 @@ func newProjectListCmd(repoFlag *string) *cobra.Command { return err }, } + + cmd.Flags().BoolVar(&all, "all", false, "list stored projects instead of patterns") return cmd } // newProjectUntrackCmd returns the "project untrack" subcommand. func newProjectUntrackCmd(repoFlag *string) *cobra.Command { var keep bool + var global bool cmd := &cobra.Command{ - Use: "untrack [--keep] ", + Use: "untrack [--keep] [--global] ", Short: "Remove a pattern from .lnkinclude and unmanage its files", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + ps := service.NewProjectService(svc(repoFlag)) + + if global { + if _, err := ps.ProjectUntrackGlobalPattern(args[0]); err != nil { + return err + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Removed '%s' from the global .lnkinclude — remember to commit it.\n", args[0]) + return err + } + projectRoot, err := projectDir(cmd) if err != nil { return err } - ps := service.NewProjectService(svc(repoFlag)) result, err := ps.ProjectUntrackPattern(cmd.Context(), projectRoot, args[0], keep) if err != nil { return err @@ -340,7 +390,7 @@ func newProjectUntrackCmd(repoFlag *string) *cobra.Command { if result.IsGlobal { app := svc(repoFlag) globalPath := filepath.Join(app.RepoPath(), ".lnkinclude") - _, err = fmt.Fprintf(cmd.ErrOrStderr(), "This pattern comes from the global .lnkinclude — edit %s to remove it.\n", globalPath) + _, err = fmt.Fprintf(cmd.ErrOrStderr(), "This pattern comes from the global .lnkinclude — edit %s to remove it, or use --global.\n", globalPath) return err } @@ -367,6 +417,7 @@ func newProjectUntrackCmd(repoFlag *string) *cobra.Command { } cmd.Flags().BoolVar(&keep, "keep", false, "only edit .lnkinclude, leaving managed files in place") + cmd.Flags().BoolVar(&global, "global", false, "remove the pattern from the lnk repo's global .lnkinclude") return cmd } @@ -923,7 +974,7 @@ func newDoctorCmd(repoFlag *string) *cobra.Command { cmd.Flags().StringVar(&host, "host", "", "check one host profile") cmd.Flags().BoolVar(&all, "all", false, "check all storage scopes") cmd.Flags().BoolVar(&fix, "fix", false, "apply safe automatic fixes") - cmd.Flags().BoolVar(&pruneEmpty, "prune-empty", false, "remove empty host scopes and their storage directories when passed with --fix") + cmd.Flags().BoolVar(&pruneEmpty, "prune-empty", false, "remove empty host scopes and project storage when passed with --fix") cmd.MarkFlagsMutuallyExclusive("all", "host") return cmd } @@ -1060,6 +1111,46 @@ func printDoctor(w io.Writer, report service.DoctorReport) error { return err } } + if len(report.Projects) > 0 { + if _, err := fmt.Fprintln(w, "Projects:"); err != nil { + return err + } + for _, p := range report.Projects { + if _, err := fmt.Fprintf(w, " %s (%d file(s))\n", p.ID, p.Files); err != nil { + return err + } + } + } + if len(report.UnmarkedProjects) > 0 { + if _, err := fmt.Fprintln(w, "Stored projects without a marker:"); err != nil { + return err + } + for _, p := range report.UnmarkedProjects { + if _, err := fmt.Fprintf(w, " %s\n", p); err != nil { + return err + } + } + } + if len(report.EmptyProjects) > 0 { + if _, err := fmt.Fprintln(w, "Empty project storage:"); err != nil { + return err + } + for _, p := range report.EmptyProjects { + if _, err := fmt.Fprintf(w, " %s\n", p); err != nil { + return err + } + } + } + if len(report.PrunedProjects) > 0 { + if _, err := fmt.Fprintln(w, "Pruned empty project storage:"); err != nil { + return err + } + for _, p := range report.PrunedProjects { + if _, err := fmt.Fprintf(w, " %s\n", p); err != nil { + return err + } + } + } if len(report.EmptyScopes) > 0 { if _, err := fmt.Fprintln(w, "\nEmpty host scopes:"); err != nil { return err diff --git a/service/doctor.go b/service/doctor.go index 6a4045f..f59d3a5 100644 --- a/service/doctor.go +++ b/service/doctor.go @@ -57,6 +57,10 @@ type DoctorReport struct { BrokenSymlinkFix bool EmptyScopes []string // host scopes with no tracked items (scan mode) PrunedScopes []string // host scopes removed by --prune-empty --fix + Projects []ProjectHealth + 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 } // HasIssues reports whether the doctor found actionable issues. @@ -64,6 +68,9 @@ 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 { + return true + } for _, result := range r.ScopeResults { if result.HasIssues() { return true @@ -167,6 +174,14 @@ func (s *Service) doctorScan(ctx context.Context, host string, all bool) (Doctor } report.EmptyScopes = empty + projects, unmarked, emptyProjects, err := s.scanProjects() + if err != nil { + return DoctorReport{}, err + } + report.Projects = projects + report.UnmarkedProjects = unmarked + report.EmptyProjects = emptyProjects + return report, nil } @@ -233,6 +248,15 @@ func (s *Service) doctorFix(ctx context.Context, host string, all, pruneEmpty bo stagePaths = append(stagePaths, paths...) } + if pruneEmpty && len(report.EmptyProjects) > 0 { + pruned, paths, err := s.pruneEmptyProjects(report.EmptyProjects) + if err != nil { + return DoctorReport{}, err + } + report.PrunedProjects = pruned + stagePaths = append(stagePaths, paths...) + } + if len(stagePaths) == 0 { return report, nil } diff --git a/service/project.go b/service/project.go index 9b82741..937eb4d 100644 --- a/service/project.go +++ b/service/project.go @@ -30,6 +30,10 @@ func NewProjectService(svc *Service) *ProjectService { return &ProjectService{svc: svc} } +// projectMarkerFile marks the storage root of one project, so that project +// directories can be enumerated even though a project ID contains slashes. +const projectMarkerFile = ".lnkproject" + // ProjectInit activates project scope for the git repo containing // projectRoot by creating an empty .lnkinclude file at the repo root if one // does not already exist. It returns true when the file was created and @@ -496,6 +500,9 @@ func (ps *ProjectService) ProjectPush(ctx context.Context, projectRoot string, f if err := os.MkdirAll(storageDir, 0o755); err != nil { return ProjectPushResult{}, fmt.Errorf("create project storage: %w", err) } + if err := ensureProjectMarker(storageDir, id); err != nil { + return ProjectPushResult{}, err + } global, err := patterns.Load(filepath.Join(ps.svc.RepoPath(), ".lnkinclude")) if err != nil { @@ -688,6 +695,9 @@ func (ps *ProjectService) releaseUnmatched(ctx context.Context, root string, eff return err } rel = filepath.ToSlash(rel) + if rel == projectMarkerFile { + return nil + } match, err := patterns.Match(effective, rel) if err != nil { @@ -754,6 +764,9 @@ func (ps *ProjectService) liveDeletions(ctx context.Context, root string, effect return err } rel = filepath.ToSlash(rel) + if rel == projectMarkerFile { + return nil + } match, err := patterns.Match(effective, rel) if err != nil { @@ -832,6 +845,9 @@ func (ps *ProjectService) ProjectSync(ctx context.Context, projectRoot string, d if err := os.MkdirAll(storageDir, 0o755); err != nil { return ProjectSyncResult{}, fmt.Errorf("create project storage: %w", err) } + if err := ensureProjectMarker(storageDir, id); err != nil { + return ProjectSyncResult{}, err + } } effective, err := ps.effectivePatterns(root) @@ -931,6 +947,9 @@ func (ps *ProjectService) ProjectRemove(ctx context.Context, projectRoot string) return err } rel = filepath.ToSlash(rel) + if rel == projectMarkerFile { + return nil + } backed, err := releaseFile(fs, path, filepath.Join(root, filepath.FromSlash(rel)), storageDir) if err != nil { @@ -1014,8 +1033,12 @@ func (ps *ProjectService) ProjectForget(ctx context.Context, projectRoot string) if err != nil { return err } + rel = filepath.ToSlash(rel) + if rel == projectMarkerFile { + return nil + } - livePath := filepath.Join(root, filepath.FromSlash(filepath.ToSlash(rel))) + livePath := filepath.Join(root, filepath.FromSlash(rel)) liveInfo, statErr := os.Lstat(livePath) if statErr != nil || liveInfo.Mode()&os.ModeSymlink == 0 { return nil @@ -1036,6 +1059,253 @@ func (ps *ProjectService) ProjectForget(ctx context.Context, projectRoot string) return result, nil } +// ensureProjectMarker writes the .lnkproject marker into a project's storage +// root, recording its ID so stored projects can be enumerated even though +// IDs contain slashes. +func ensureProjectMarker(storageDir, id string) error { + marker := filepath.Join(storageDir, projectMarkerFile) + if _, err := os.Stat(marker); err == nil { + return nil + } + if err := os.WriteFile(marker, []byte(id+"\n"), 0o644); err != nil { + return fmt.Errorf("write project marker: %w", err) + } + return nil +} + +// ProjectAddGlobalPattern appends a pattern to the lnk repo's global +// .lnkinclude. Global patterns are stored verbatim (they apply to every +// project, so they cannot be relativized against one root) and are not +// match-checked. +func (ps *ProjectService) ProjectAddGlobalPattern(rawPattern string) (string, error) { + pattern := strings.TrimSpace(rawPattern) + if pattern == "" || strings.TrimPrefix(pattern, "!") == "" { + return "", lnkerror.Wrap(lnkerror.ErrEmptyPattern) + } + + manifest := filepath.Join(ps.svc.RepoPath(), ".lnkinclude") + existing, err := patterns.Load(manifest) + if err != nil { + return "", fmt.Errorf("load global .lnkinclude: %w", err) + } + if slices.Contains(existing, pattern) { + return "", lnkerror.WithPath(lnkerror.ErrAlreadyManaged, pattern) + } + + if err := appendPattern(manifest, pattern); err != nil { + return "", err + } + return pattern, nil +} + +// ProjectUntrackGlobalPattern removes a pattern from the lnk repo's global +// .lnkinclude. It returns removed=true when the pattern was present. +func (ps *ProjectService) ProjectUntrackGlobalPattern(pattern string) (bool, error) { + manifest := filepath.Join(ps.svc.RepoPath(), ".lnkinclude") + lines, err := patterns.Load(manifest) + if err != nil { + return false, fmt.Errorf("load global .lnkinclude: %w", err) + } + if !slices.Contains(lines, pattern) { + return false, lnkerror.WithPath(lnkerror.ErrNotManaged, pattern) + } + if err := rewritePatterns(manifest, lines, pattern); err != nil { + return false, err + } + return true, nil +} + +// StoredProject describes one project discovered in lnk storage. +type StoredProject struct { + ID string + Files int +} + +// ProjectListProjects returns the projects discovered in lnk storage via +// their .lnkproject markers, sorted by ID. +func (ps *ProjectService) ProjectListProjects() ([]StoredProject, error) { + root := filepath.Join(ps.svc.RepoPath(), "projects") + if _, err := os.Stat(root); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("scan projects: %w", err) + } + + var result []StoredProject + err := filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.IsDir() || info.Name() != projectMarkerFile { + return nil + } + id, err := filepath.Rel(root, filepath.Dir(path)) + if err != nil { + return err + } + id = filepath.ToSlash(id) + files, err := countProjectFiles(filepath.Dir(path)) + if err != nil { + return err + } + result = append(result, StoredProject{ID: id, Files: files}) + return nil + }) + if err != nil { + return nil, err + } + + slices.SortFunc(result, func(a, b StoredProject) int { + return strings.Compare(a.ID, b.ID) + }) + return result, nil +} + +// ProjectHealth captures the storage-side health of one project for doctor. +type ProjectHealth struct { + ID string + Files int +} + +// scanProjects returns the stored projects, top-level storage entries +// without a marker, and marked projects with no files. +func (s *Service) scanProjects() (projects []ProjectHealth, unmarked, empty []string, err error) { + root := filepath.Join(s.repoPath, "projects") + if _, err := os.Stat(root); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil, nil, nil + } + return nil, nil, nil, fmt.Errorf("scan projects: %w", err) + } + + markerDirs := make(map[string]struct{}) + err = filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.IsDir() || info.Name() != projectMarkerFile { + return nil + } + markerDirs[filepath.Dir(path)] = struct{}{} + return nil + }) + if err != nil { + return nil, nil, nil, err + } + + for dir := range markerDirs { + id, err := filepath.Rel(root, dir) + if err != nil { + return nil, nil, nil, err + } + id = filepath.ToSlash(id) + files, err := countProjectFiles(dir) + if err != nil { + return nil, nil, nil, err + } + if files == 0 { + empty = append(empty, id) + } else { + projects = append(projects, ProjectHealth{ID: id, Files: files}) + } + } + slices.SortFunc(projects, func(a, b ProjectHealth) int { + return strings.Compare(a.ID, b.ID) + }) + slices.Sort(empty) + + entries, err := os.ReadDir(root) + if err != nil { + return nil, nil, nil, err + } + for _, e := range entries { + name := e.Name() + if !e.IsDir() { + unmarked = append(unmarked, name) + continue + } + hasMarker, err := dirContainsMarker(filepath.Join(root, name)) + if err != nil { + return nil, nil, nil, err + } + if hasMarker { + continue + } + hasFiles, err := dirContainsFiles(filepath.Join(root, name)) + if err != nil { + return nil, nil, nil, err + } + if hasFiles { + unmarked = append(unmarked, name) + } + } + slices.Sort(unmarked) + + return projects, unmarked, empty, nil +} + +// countProjectFiles counts regular files under dir, excluding the project +// marker itself. +func countProjectFiles(dir string) (int, error) { + n := 0 + err := filepath.Walk(dir, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.IsDir() || info.Name() == projectMarkerFile { + return nil + } + n++ + return nil + }) + return n, err +} + +func dirContainsMarker(dir string) (bool, error) { + found := false + err := filepath.Walk(dir, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !info.IsDir() && info.Name() == projectMarkerFile { + found = true + } + return nil + }) + return found, err +} + +func dirContainsFiles(dir string) (bool, error) { + found := false + err := filepath.Walk(dir, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !info.IsDir() { + found = true + } + return nil + }) + return found, err +} + +// pruneEmptyProjects removes the storage directory of each empty project and +// returns the pruned IDs and the marker paths to stage for removal. +func (s *Service) pruneEmptyProjects(ids []string) ([]string, []string, error) { + var pruned []string + var stagePaths []string + for _, id := range ids { + dir := filepath.Join(s.repoPath, "projects", filepath.FromSlash(id)) + if err := os.RemoveAll(dir); err != nil { + return nil, nil, fmt.Errorf("remove empty project storage %s: %w", id, err) + } + stagePaths = append(stagePaths, filepath.ToSlash(filepath.Join("projects", id, projectMarkerFile))) + pruned = append(pruned, id) + } + return pruned, stagePaths, nil +} + // ProjectRestore recreates symlinks for all project-scoped files from // storage. Live files tracked by the project's own git index are left // untouched (and reported in RestoreInfo.SkippedTracked) unless force is @@ -1088,6 +1358,9 @@ func (ps *ProjectService) ProjectRestore(ctx context.Context, projectRoot string return err } rel = filepath.ToSlash(rel) + if rel == projectMarkerFile { + return nil + } // Stored files whose patterns no longer match are drift, not state to // recreate; 'lnk project sync' moves them back. diff --git a/service/project_test.go b/service/project_test.go index 896c7f0..e95d47c 100644 --- a/service/project_test.go +++ b/service/project_test.go @@ -1459,3 +1459,219 @@ func TestProjectRestore_GatesOnPatterns(t *testing.T) { } testhelpers.AssertSymlink(t, notesLive, filepath.Join(storageDir, "notes.md")) } + +// ---------- Global patterns, discovery, and doctor ---------- + +func TestProjectAddGlobalPattern(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + + pattern, err := ps.ProjectAddGlobalPattern("AGENTS.md") + if err != nil { + t.Fatalf("ProjectAddGlobalPattern: %v", err) + } + if pattern != "AGENTS.md" { + t.Errorf("pattern = %q, want AGENTS.md", pattern) + } + + globalPath := filepath.Join(svc.RepoPath(), ".lnkinclude") + data, err := os.ReadFile(globalPath) + if err != nil { + t.Fatal(err) + } + if string(data) != "AGENTS.md\n" { + t.Errorf("global .lnkinclude = %q", data) + } + + // Duplicate. + if _, err := ps.ProjectAddGlobalPattern("AGENTS.md"); !errors.Is(err, lnkerror.ErrAlreadyManaged) { + t.Errorf("duplicate error = %v, want %v", err, lnkerror.ErrAlreadyManaged) + } + + // Empty. + if _, err := ps.ProjectAddGlobalPattern(" "); !errors.Is(err, lnkerror.ErrEmptyPattern) { + t.Errorf("empty error = %v, want %v", err, lnkerror.ErrEmptyPattern) + } +} + +func TestProjectUntrackGlobalPattern(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + if _, err := ps.ProjectAddGlobalPattern("AGENTS.md"); err != nil { + t.Fatalf("add: %v", err) + } + + removed, err := ps.ProjectUntrackGlobalPattern("AGENTS.md") + if err != nil { + t.Fatalf("ProjectUntrackGlobalPattern: %v", err) + } + if !removed { + t.Error("expected removed=true") + } + + globalPath := filepath.Join(svc.RepoPath(), ".lnkinclude") + data, err := os.ReadFile(globalPath) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(data)) != "" { + t.Errorf("global .lnkinclude = %q, want empty", data) + } + + if _, err := ps.ProjectUntrackGlobalPattern("nope.md"); !errors.Is(err, lnkerror.ErrNotManaged) { + t.Errorf("missing error = %v, want %v", err, lnkerror.ErrNotManaged) + } +} + +func TestProjectListProjects(t *testing.T) { + svc, home := testhelpers.TestHome(t) + + // First project, two files. + dir1 := filepath.Join(home, "repos", "alpha") + testhelpers.MakeDir(t, dir1) + initProjectRepo(t, dir1) + ps1 := service.NewProjectService(svc) + if _, _, err := ps1.ProjectAddPattern(context.Background(), dir1, ".todo/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + testhelpers.MakeFile(t, filepath.Join(dir1, ".todo", "a.md"), "a\n") + testhelpers.MakeFile(t, filepath.Join(dir1, ".todo", "b.md"), "b\n") + if _, err := ps1.ProjectPush(context.Background(), dir1, false); err != nil { + t.Fatalf("ProjectPush: %v", err) + } + + // Second project with a different origin, one file. + dir2 := filepath.Join(home, "repos", "beta") + testhelpers.MakeDir(t, dir2) + testhelpers.InitGitRepo(t, dir2) + if out, err := exec.Command("git", "-C", dir2, "remote", "add", "origin", "git@github.com:User/Other.git").CombinedOutput(); err != nil { + t.Fatalf("git remote add: %v\n%s", err, out) + } + ps2 := service.NewProjectService(svc) + if _, _, err := ps2.ProjectAddPattern(context.Background(), dir2, "notes.md"); err != nil { + t.Fatalf("add pattern: %v", err) + } + testhelpers.MakeFile(t, filepath.Join(dir2, "notes.md"), "notes\n") + if _, err := ps2.ProjectPush(context.Background(), dir2, false); err != nil { + t.Fatalf("ProjectPush: %v", err) + } + + projects, err := ps1.ProjectListProjects() + if err != nil { + t.Fatalf("ProjectListProjects: %v", err) + } + if len(projects) != 2 { + t.Fatalf("projects = %v, want 2", projects) + } + + byID := map[string]int{} + for _, p := range projects { + byID[p.ID] = p.Files + } + if byID["github.com/user/repo"] != 2 { + t.Errorf("github.com/user/repo files = %d, want 2", byID["github.com/user/repo"]) + } + if byID["github.com/user/other"] != 1 { + t.Errorf("github.com/user/other files = %d, want 1", byID["github.com/user/other"]) + } + + // Sorted by ID. + if projects[0].ID > projects[1].ID { + t.Errorf("projects not sorted: %v", projects) + } +} + +func TestProjectMarkerNotRestored(t *testing.T) { + svc, ps, repoDir, _ := newPushedProject(t, ".cursor/**", ".cursor/rules.md") + + if err := os.Remove(filepath.Join(repoDir, ".cursor", "rules.md")); err != nil { + t.Fatal(err) + } + info, err := ps.ProjectRestore(context.Background(), repoDir, false, false) + if err != nil { + t.Fatalf("ProjectRestore: %v", err) + } + if len(info.Restored) != 1 || info.Restored[0] != ".cursor/rules.md" { + t.Errorf("restored = %v, want [.cursor/rules.md]", info.Restored) + } + if testhelpers.FileExists(t, filepath.Join(repoDir, ".lnkproject")) { + t.Error("expected .lnkproject marker not to be restored into the project") + } + if !testhelpers.FileExists(t, filepath.Join(svc.RepoPath(), "projects", "github.com", "user", "repo", ".lnkproject")) { + t.Error("expected .lnkproject marker in storage") + } +} + +func TestDoctor_ReportsStoredProjects(t *testing.T) { + svc, _, _, _ := newPushedProject(t, ".todo/**", ".todo/a.md", ".todo/b.md") + + report, err := svc.Doctor(context.Background(), "", true, false, false) + if err != nil { + t.Fatalf("Doctor: %v", err) + } + if len(report.Projects) != 1 { + t.Fatalf("projects = %v, want 1", report.Projects) + } + if report.Projects[0].ID != "github.com/user/repo" || report.Projects[0].Files != 2 { + t.Errorf("project = %+v, want github.com/user/repo with 2 files", report.Projects[0]) + } + if len(report.UnmarkedProjects) != 0 || len(report.EmptyProjects) != 0 { + t.Errorf("unmarked = %v, empty = %v, want none", report.UnmarkedProjects, report.EmptyProjects) + } +} + +func TestDoctor_FlagsUnmarkedAndEmptyProjects(t *testing.T) { + svc, _ := testhelpers.TestHome(t) + projectsRoot := filepath.Join(svc.RepoPath(), "projects") + + // Unmarked: files under projects/ with no marker anywhere. + unmarkedDir := filepath.Join(projectsRoot, "legacy") + testhelpers.MakeFile(t, filepath.Join(unmarkedDir, "x.md"), "x\n") + + // Empty: a marker but no stored files. + emptyDir := filepath.Join(projectsRoot, "emptied") + testhelpers.MakeDir(t, emptyDir) + if err := os.WriteFile(filepath.Join(emptyDir, ".lnkproject"), []byte("emptied\n"), 0o644); err != nil { + t.Fatal(err) + } + + report, err := svc.Doctor(context.Background(), "", true, false, false) + if err != nil { + t.Fatalf("Doctor: %v", err) + } + if len(report.UnmarkedProjects) != 1 || report.UnmarkedProjects[0] != "legacy" { + t.Errorf("unmarked = %v, want [legacy]", report.UnmarkedProjects) + } + if len(report.EmptyProjects) != 1 || report.EmptyProjects[0] != "emptied" { + t.Errorf("empty = %v, want [emptied]", report.EmptyProjects) + } +} + +func TestDoctor_PrunesEmptyProjects(t *testing.T) { + svc, _ := testhelpers.TestHome(t) + emptyDir := filepath.Join(svc.RepoPath(), "projects", "emptied") + testhelpers.MakeDir(t, emptyDir) + if err := os.WriteFile(filepath.Join(emptyDir, ".lnkproject"), []byte("emptied\n"), 0o644); err != nil { + t.Fatal(err) + } + testhelpers.CommitFile(t, svc.RepoPath(), "projects/emptied/.lnkproject") + + report, err := svc.Doctor(context.Background(), "", true, true, true) + if err != nil { + t.Fatalf("Doctor: %v", err) + } + if len(report.PrunedProjects) != 1 || report.PrunedProjects[0] != "emptied" { + t.Errorf("pruned = %v, want [emptied]", report.PrunedProjects) + } + if testhelpers.FileExists(t, emptyDir) { + t.Error("expected empty project storage to be removed") + } +}