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..140709e 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,55 @@ 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 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 +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 +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 + +# 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. + +```bash +make man # generate pages in man/ +man man/lnk-project-push.1 # read a generated page +``` + ## Commands | Command | What it does | @@ -204,6 +253,16 @@ 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 [--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 @@ -215,10 +274,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/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 a716e65..d62b97f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "github.com/spf13/cobra" @@ -167,21 +168,510 @@ 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)) + 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 } +// projectDir resolves the --dir flag (defaulting to the current working +// 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 == "" { + 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) + } + + 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", + var global bool + + cmd := &cobra.Command{ + 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 + } + + for _, pattern := range args { + 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 + }, + } + + 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 [--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 + } + + 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 + }, + } + + 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] [--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 + } + + result, err := ps.ProjectUntrackPattern(cmd.Context(), projectRoot, args[0], keep) + if err != nil { + return err + } + + 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, or use --global.\n", globalPath) + 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") + cmd.Flags().BoolVar(&global, "global", false, "remove the pattern from the lnk repo's global .lnkinclude") + 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 +} + +// 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 + + 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) + if err != nil { + return err + } + + ps := service.NewProjectService(svc(repoFlag)) + 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 { + 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 { + return err + } + for _, path := range result.Synced { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), " %s\n", path); err != nil { + return err + } + } + 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] [--force]", + 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, force) + if err != nil { + return err + } + return printRestore(cmd.OutOrStdout(), info, dryRun) + }, + } + + 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 { + 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) + if err != nil { + return err + } + + ps := service.NewProjectService(svc(repoFlag)) + info, err := ps.ProjectPull(cmd.Context(), projectRoot, force) + 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 }, } + + cmd.Flags().BoolVar(&force, "force", false, "replace files tracked by the project's own git") + return cmd } // newMoveCmd returns the "move" subcommand. @@ -484,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 } @@ -556,16 +1046,38 @@ 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 && len(info.SkippedUnmatched) == 0 { return nil } - if _, err := fmt.Fprintf(w, "%s %d conflicting path(s)\n", backupPrefix, len(info.BackedUp)); 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.BackedUp { - 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 } @@ -599,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/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/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/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/internal/lnkerror/error.go b/internal/lnkerror/error.go index 881a2ae..6ac5f2d 100644 --- a/internal/lnkerror/error.go +++ b/internal/lnkerror/error.go @@ -22,6 +22,11 @@ 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") + 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/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 new file mode 100644 index 0000000..937eb4d --- /dev/null +++ b/service/project.go @@ -0,0 +1,1439 @@ +package service + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "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" + "github.com/polymorcodeus/lnk/internal/resolver" + "github.com/polymorcodeus/lnk/internal/scope" +) + +// 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} +} + +// 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 +// false when it already existed. +func (ps *ProjectService) ProjectInit(ctx context.Context, projectRoot string) (bool, error) { + root, err := ps.resolveProjectRoot(ctx, projectRoot) + if err != nil { + return false, err + } + + manifest := filepath.Join(root, ".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 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 "", false, err + } + + pattern, err := normalizePattern(root, rawPattern) + if err != nil { + return "", false, err + } + + manifest := filepath.Join(root, ".lnkinclude") + existing, err := patterns.Load(manifest) + if err != nil { + return "", false, fmt.Errorf("load .lnkinclude: %w", err) + } + if slices.Contains(existing, pattern) { + return "", false, lnkerror.WithPath(lnkerror.ErrAlreadyManaged, pattern) + } + + 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, matched, nil +} + +// ProjectListPatterns returns the effective patterns for a project, split +// 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) { + root, err := ps.resolveProjectRoot(ctx, projectRoot) + if err != nil { + return nil, nil, 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(root, ".lnkinclude")) + if err != nil { + return nil, nil, fmt.Errorf("load local .lnkinclude: %w", err) + } + + 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. 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 ProjectUntrackResult{}, err + } + + localPath := filepath.Join(root, ".lnkinclude") + localLines, err := patterns.Load(localPath) + if err != nil { + return ProjectUntrackResult{}, fmt.Errorf("load local .lnkinclude: %w", err) + } + + // 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 !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) + } + if slices.Contains(global, target) { + return ProjectUntrackResult{IsGlobal: true}, nil + } + return ProjectUntrackResult{}, lnkerror.WithPath(lnkerror.ErrNotManaged, pattern) + } + + 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 result, err + } + released, backedUp, err := ps.releaseUnmatched(ctx, root, effective, false) + if err != nil { + return result, err + } + result.Released = released + result.BackedUp = backedUp + + 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 +// 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 "", fmt.Errorf("resolve project root: %w", err) + } + + root, err := gitboundary.ResolveGitRoot(ctx, abs) + if err != nil { + return "", err + } + if root == "" { + return "", lnkerror.WithPathAndSuggestion(lnkerror.ErrOutsideGitRepo, abs, "use 'lnk add' for host/common scope") + } + + 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 +} + +// 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 err + } + if ok { + return errPatternMatched + } + return nil + }) + if errors.Is(err, errPatternMatched) { + return true, nil + } + if err != nil { + return false, err + } + 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 nil, fmt.Errorf("list project-tracked files: %w\n%s", err, out) + } + tracked := make(map[string]struct{}) + for p := range strings.SplitSeq(string(out), "\x00") { + if p != "" { + tracked[p] = struct{}{} + } + } + return tracked, nil +} + +// 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) + } + 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 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 +} + +// ProjectPushResult reports the files moved to storage and symlinked back by +// ProjectPush. +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. 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{}, err + } + + id, err := ps.projectID(ctx, root) + if err != nil { + return ProjectPushResult{}, 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) + } + if err := ensureProjectMarker(storageDir, id); err != nil { + return ProjectPushResult{}, err + } + + 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(root, ".lnkinclude")) + if err != nil { + return ProjectPushResult{}, fmt.Errorf("load local .lnkinclude: %w", err) + } + + 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 + } + + 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, + } + + 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 + } + match, err := patterns.Match(effective, rel) + if err != nil { + return err + } + if !match { + return nil + } + if !force { + if _, ok := tracked[rel]; ok { + 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) + if rel == projectMarkerFile { + return nil + } + + 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 + } + 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) + if rel == projectMarkerFile { + return nil + } + + 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) + } + if err := ensureProjectMarker(storageDir, id); err != nil { + return ProjectSyncResult{}, 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 + + result.Released, result.BackedUp, err = ps.releaseUnmatched(ctx, root, effective, dryRun) + if err != nil { + return result, err + } + + result.Deletions, result.Pruned, err = ps.liveDeletions(ctx, root, effective, dryRun, pruneDeletions) + if err != nil { + return result, err + } + + 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) + if rel == projectMarkerFile { + 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.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 +} + +// 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 + } + + id, err := ps.projectID(ctx, root) + if err != nil { + return ProjectForgetResult{}, err + } + + 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 + } + if fi.IsDir() { + return nil + } + rel, err := filepath.Rel(storageDir, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if rel == projectMarkerFile { + return nil + } + + livePath := filepath.Join(root, filepath.FromSlash(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 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 +// 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{}, err + } + + id, err := ps.projectID(ctx, root) + if err != nil { + 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, root, "run 'lnk project push' first") + } + + tracked, err := projectTrackedFiles(ctx, root) + if err != nil { + return RestoreInfo{}, err + } + + effective, err := ps.effectivePatterns(root) + if err != nil { + return RestoreInfo{}, err + } + + r := &scope.ProjectRootResolver{ + GitRoot: root, + StorageDir: storageDir, + } + + info := RestoreInfo{} + fs := &fspkg.FileSystem{} + + 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) + if rel == projectMarkerFile { + return nil + } + + // 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 + } + + 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 + } + } + + if liveExists { + if liveIsSymlink { + if !dryRun { + if err := os.Remove(livePath); err != nil { + return fmt.Errorf("replace symlink %s: %w", livePath, err) + } + } + } 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, force bool) (RestoreInfo, error) { + if err := ps.svc.git.Pull(ctx); err != nil { + return RestoreInfo{}, err + } + return ps.ProjectRestore(ctx, projectRoot, false, force) +} 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) - } -} diff --git a/service/project_test.go b/service/project_test.go new file mode 100644 index 0000000..e95d47c --- /dev/null +++ b/service/project_test.go @@ -0,0 +1,1677 @@ +package service_test + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "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_FromSubdirAnchorsAtGitRoot(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + subDir := filepath.Join(repoDir, "sub", "dir") + testhelpers.MakeDir(t, subDir) + initProjectRepo(t, repoDir) + + ps := service.NewProjectService(svc) + 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 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", 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) + } + } +} + +func TestProjectAddPattern_AppendsAndNormalizes(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + + liveFile := filepath.Join(repoDir, ".cursor", "rules.md") + testhelpers.MakeFile(t, liveFile, "# rules\n") + + 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) + if err != nil { + t.Fatal(err) + } + if string(content) != filepath.Join(".cursor", "rules.md")+"\n" { + t.Errorf(".lnkinclude content = %q", string(content)) + } +} + +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") + 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.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) + } + } +} + +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 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") + 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) + } + + result, err := ps.ProjectUntrackPattern(context.Background(), repoDir, "agents.md", true) + if err != nil { + t.Fatalf("ProjectUntrackPattern: %v", err) + } + if !result.Removed { + t.Error("expected removed=true") + } + if result.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) + result, err := ps.ProjectUntrackPattern(context.Background(), repoDir, ".todo/**", true) + if err != nil { + t.Fatalf("ProjectUntrackPattern: %v", err) + } + if result.Removed { + t.Error("expected removed=false") + } + if !result.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(context.Background(), repoDir, "missing.md", true) + 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) + } +} + +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, false) + 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_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") + 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("first push: %v", err) + } + + result, err := ps.ProjectPush(context.Background(), repoDir, false) + 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, false) + 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") + + // 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) + } + 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_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, "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) + } + 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) + } + + // 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") + } +} + +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) + } + + storageFile := filepath.Join(svc.RepoPath(), "projects", result.ProjectID, "notes.md") + if !testhelpers.FileExists(t, storageFile) { + t.Errorf("expected storage file %s", storageFile) + } +} + +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, false) + 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, 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, false) + 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, 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_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") + 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) + } + + if err := os.Remove(liveFile); err != nil { + t.Fatalf("remove symlink: %v", err) + } + + info, err := ps.ProjectRestore(context.Background(), repoDir, true, false) + 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, false) + 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, false) + 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) +} + +// ---------- 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")) +} + +// ---------- 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") + } +} diff --git a/service/service.go b/service/service.go index f227fda..95eddad 100644 --- a/service/service.go +++ b/service/service.go @@ -76,6 +76,12 @@ 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 + // 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. 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) +}