diff --git a/README.md b/README.md index 6537b90..a8083ad 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ lnk doctor --all # check all scopes `lnk doctor` checks project scope as well as host/common scope: it reports orphaned project storage, broken project symlinks, and `.lnkinclude` patterns that match no files. Project issues are listed with severity and a suggested fix. -When restoring symlinks, if a real file exists at the target location (not a symlink), it will be renamed to `.lnk-backup` to preserve your data before the symlink is created. Check for `.lnk-backup` files after running `restore`, `update`, or `doctor` if you expect them. +When restoring symlinks, if a real file exists at the target location (not a symlink), it will be renamed to `.lnk-backup` to preserve your data before the symlink is created. Check for `.lnk-backup` files after running `restore`, `update`, or `doctor` if you expect them. The git hook path (`lnk hooks run ...`) is collision-safe and does not create `.lnk-backup` files; it reports collisions to stderr and leaves the real file in place. ### Format migration @@ -233,6 +233,21 @@ Matched files are stored under `projects///` in your ln - **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`. +### Hooks + +Install opt-in git hooks so lnk restores symlinks automatically after git operations. + +```bash +lnk hooks install # post-merge hook in ~/.config/lnk +lnk hooks install --project # post-checkout hook in current project repo +lnk hooks uninstall # remove lnk's post-merge hook +lnk hooks uninstall --project # remove lnk's post-checkout hook +``` + +The `post-merge` hook runs inside the lnk repo after a `git pull` and recreates any missing common-scope symlinks. The `post-checkout` hook runs inside a project repo after switching branches and recreates missing project-scope symlinks. Both hooks are collision-safe: if a real file occupies a symlink target, the hook reports the collision to stderr and leaves the file untouched (no `.lnk-backup`). + +Hooks are installed as shell scripts that delegate to `lnk hooks run `, so they always use the same lnk binary that was present at install time. + ## Man pages Man pages are generated from the Cobra command tree and ship with release archives. @@ -273,6 +288,9 @@ man man/lnk-project-push.1 # read a generated page | `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 | +| `hooks install [--project]` | Install lnk's git hooks | +| `hooks uninstall [--project]` | Remove lnk's git hooks | +| `hooks run [args...]` | Entry point used by installed git hook scripts | ## Global Options diff --git a/VERSION b/VERSION index 1defe53..a4b6ac3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v2.1.0 +v2.2.0 diff --git a/cmd/hooks.go b/cmd/hooks.go new file mode 100644 index 0000000..9954caa --- /dev/null +++ b/cmd/hooks.go @@ -0,0 +1,127 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/polymorcodeus/lnk/internal/gitboundary" + "github.com/polymorcodeus/lnk/internal/hooks" +) + +// newHooksCmd returns the "hooks" command group. +func newHooksCmd(repoFlag *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "hooks", + Short: "Install and run git hooks for lnk", + } + cmd.AddCommand(newHooksInstallCmd(repoFlag)) + cmd.AddCommand(newHooksUninstallCmd(repoFlag)) + cmd.AddCommand(newHooksRunCmd(repoFlag)) + return cmd +} + +// newHooksInstallCmd returns the "hooks install" subcommand. +func newHooksInstallCmd(repoFlag *string) *cobra.Command { + var project bool + + cmd := &cobra.Command{ + Use: "install [--project]", + Short: "Install lnk git hooks", + RunE: func(cmd *cobra.Command, args []string) error { + lnkBinary, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve lnk executable: %w", err) + } + + if project { + projectRoot, err := resolveProjectRoot(cmd.Context()) + if err != nil { + return err + } + if err := hooks.InstallProject(projectRoot, lnkBinary); err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), "Installed post-checkout hook in project") + return err + } + + app := svc(repoFlag) + if err := hooks.InstallLnkRepo(app.RepoPath(), lnkBinary); err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), "Installed post-merge hook in lnk repo") + return err + }, + } + + cmd.Flags().BoolVar(&project, "project", false, "install the post-checkout hook in the current project repo") + return cmd +} + +// newHooksUninstallCmd returns the "hooks uninstall" subcommand. +func newHooksUninstallCmd(repoFlag *string) *cobra.Command { + var project bool + + cmd := &cobra.Command{ + Use: "uninstall [--project]", + Short: "Uninstall lnk git hooks", + RunE: func(cmd *cobra.Command, args []string) error { + if project { + projectRoot, err := resolveProjectRoot(cmd.Context()) + if err != nil { + return err + } + if err := hooks.UninstallProject(projectRoot); err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), "Uninstalled post-checkout hook from project") + return err + } + + app := svc(repoFlag) + if err := hooks.UninstallLnkRepo(app.RepoPath()); err != nil { + return err + } + _, err := fmt.Fprintln(cmd.OutOrStdout(), "Uninstalled post-merge hook from lnk repo") + return err + }, + } + + cmd.Flags().BoolVar(&project, "project", false, "uninstall the post-checkout hook from the current project repo") + return cmd +} + +// newHooksRunCmd returns the "hooks run" subcommand. +func newHooksRunCmd(repoFlag *string) *cobra.Command { + return &cobra.Command{ + Use: "run [args...]", + Short: "Run a lnk hook (used by installed git hook scripts)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + app := svc(repoFlag) + return app.RunHook(cmd.Context(), args[0], args[1:], cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + } +} + +// resolveProjectRoot returns the root of the project repository containing +// the current working directory. +func resolveProjectRoot(ctx context.Context) (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("get working directory: %w", err) + } + + root, err := gitboundary.ResolveGitRoot(ctx, cwd) + if err != nil { + return "", err + } + if root == "" { + return "", fmt.Errorf("not inside a git repository") + } + + return root, nil +} diff --git a/cmd/root.go b/cmd/root.go index 70a9ca9..018a57b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -58,6 +58,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newBootstrapCmd(&repoPath)) rootCmd.AddCommand(newFormatCmd(&repoPath)) rootCmd.AddCommand(newHomeCmd(&repoPath)) + rootCmd.AddCommand(newHooksCmd(&repoPath)) return rootCmd } @@ -1059,6 +1060,16 @@ func printRestore(w io.Writer, info service.RestoreInfo, dryRun bool) error { } } } + if len(info.Collisions) > 0 { + if _, err := fmt.Fprintf(w, "Skipped %d path(s) with existing files (collisions reported by hook)\n", len(info.Collisions)); err != nil { + return err + } + for _, path := range info.Collisions { + if _, err := fmt.Fprintf(w, " %s\n", path); err != nil { + return err + } + } + } if len(info.SkippedTracked) == 0 && len(info.SkippedUnmatched) == 0 { return nil } diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go new file mode 100644 index 0000000..ca89f2f --- /dev/null +++ b/internal/hooks/hooks.go @@ -0,0 +1,105 @@ +// Package hooks installs and manages git hooks for lnk. +package hooks + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/polymorcodeus/lnk/internal/lnkerror" +) + +// Marker comments bracket a hook script written by lnk. +const ( + markerBegin = "# >>> lnk hook begin" + markerEnd = "# >>> lnk hook end" +) + +// Hook names installed by lnk. +const ( + LnkRepoHook = "post-merge" + ProjectHook = "post-checkout" +) + +// InstallLnkRepo installs the post-merge hook in the lnk repository at +// repoPath. The hook invokes lnkBinary via 'lnk hooks run post-merge'. +func InstallLnkRepo(repoPath, lnkBinary string) error { + return install(filepath.Join(repoPath, ".git", "hooks"), LnkRepoHook, lnkBinary, LnkRepoHook) +} + +// InstallProject installs the post-checkout hook in a project repository at +// repoRoot. The hook invokes lnkBinary via 'lnk hooks run post-checkout'. +func InstallProject(repoRoot, lnkBinary string) error { + return install(filepath.Join(repoRoot, ".git", "hooks"), ProjectHook, lnkBinary, ProjectHook) +} + +// UninstallLnkRepo removes the post-merge hook from the lnk repository. +func UninstallLnkRepo(repoPath string) error { + return uninstall(filepath.Join(repoPath, ".git", "hooks"), LnkRepoHook) +} + +// UninstallProject removes the post-checkout hook from a project repository. +func UninstallProject(repoRoot string) error { + return uninstall(filepath.Join(repoRoot, ".git", "hooks"), ProjectHook) +} + +// IsInstalled reports whether the named hook in gitDir is managed by lnk. +func IsInstalled(gitDir, hookName string) bool { + path := filepath.Join(gitDir, hookName) + data, err := os.ReadFile(path) + if err != nil { + return false + } + return strings.Contains(string(data), markerBegin) +} + +// install writes a shell hook script that delegates to lnkBinary. It refuses +// to overwrite an existing hook that was not written by lnk. +func install(gitDir, hookName, lnkBinary, runName string) error { + if err := os.MkdirAll(gitDir, 0o755); err != nil { + return fmt.Errorf("create hooks directory: %w", err) + } + + path := filepath.Join(gitDir, hookName) + if data, err := os.ReadFile(path); err == nil && len(data) > 0 { + if !strings.Contains(string(data), markerBegin) { + return lnkerror.WithPath(lnkerror.ErrForeignHook, path) + } + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read existing hook %s: %w", path, err) + } + + script := fmt.Sprintf(`#!/bin/sh +%s +# generated by lnk: do not edit +exec %s hooks run %s "$@" +%s +`, markerBegin, lnkBinary, runName, markerEnd) + + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + return fmt.Errorf("write hook %s: %w", path, err) + } + return nil +} + +// uninstall removes a lnk-managed hook. It is a no-op when the hook does not +// exist or is not managed by lnk. +func uninstall(gitDir, hookName string) error { + path := filepath.Join(gitDir, hookName) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("read hook %s: %w", path, err) + } + if !strings.Contains(string(data), markerBegin) { + return nil + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove hook %s: %w", path, err) + } + return nil +} diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go new file mode 100644 index 0000000..7e2aed1 --- /dev/null +++ b/internal/hooks/hooks_test.go @@ -0,0 +1,138 @@ +package hooks_test + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/polymorcodeus/lnk/internal/hooks" + "github.com/polymorcodeus/lnk/internal/lnkerror" +) + +func TestInstallLnkRepo_WritesPostMergeHook(t *testing.T) { + dir := t.TempDir() + if err := hooks.InstallLnkRepo(dir, "/usr/local/bin/lnk"); err != nil { + t.Fatalf("InstallLnkRepo: %v", err) + } + + hookPath := filepath.Join(dir, ".git", "hooks", "post-merge") + info, err := os.Stat(hookPath) + if err != nil { + t.Fatalf("stat hook: %v", err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Error("expected hook to be executable") + } + + data, err := os.ReadFile(hookPath) + if err != nil { + t.Fatal(err) + } + content := string(data) + if !hooks.IsInstalled(filepath.Join(dir, ".git", "hooks"), hooks.LnkRepoHook) { + t.Error("expected IsInstalled to return true") + } + if !strings.Contains(content, "hooks run post-merge") { + t.Errorf("hook content missing expected command: %s", content) + } +} + +func TestInstallProject_WritesPostCheckoutHook(t *testing.T) { + repoRoot := t.TempDir() + if err := hooks.InstallProject(repoRoot, "/usr/local/bin/lnk"); err != nil { + t.Fatalf("InstallProject: %v", err) + } + + hookPath := filepath.Join(repoRoot, ".git", "hooks", "post-checkout") + if _, err := os.Stat(hookPath); err != nil { + t.Fatalf("stat hook: %v", err) + } + + data, err := os.ReadFile(hookPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "hooks run post-checkout") { + t.Errorf("hook content missing expected command: %s", string(data)) + } +} + +func TestInstall_RefusesForeignHook(t *testing.T) { + dir := t.TempDir() + hooksDir := filepath.Join(dir, ".git", "hooks") + if err := os.MkdirAll(hooksDir, 0o755); err != nil { + t.Fatal(err) + } + hookPath := filepath.Join(hooksDir, "post-merge") + if err := os.WriteFile(hookPath, []byte("#!/bin/sh\necho foreign\n"), 0o755); err != nil { + t.Fatal(err) + } + + err := hooks.InstallLnkRepo(dir, "/usr/local/bin/lnk") + if err == nil { + t.Fatal("expected error when foreign hook exists") + } + if !errors.Is(err, lnkerror.ErrForeignHook) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrForeignHook) + } +} + +func TestInstall_ReplacesOwnHook(t *testing.T) { + dir := t.TempDir() + if err := hooks.InstallLnkRepo(dir, "/old/lnk"); err != nil { + t.Fatalf("InstallLnkRepo: %v", err) + } + if err := hooks.InstallLnkRepo(dir, "/new/lnk"); err != nil { + t.Fatalf("InstallLnkRepo second time: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, ".git", "hooks", "post-merge")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "/new/lnk") { + t.Errorf("hook did not update binary path: %s", string(data)) + } +} + +func TestUninstall_RemovesOwnHook(t *testing.T) { + dir := t.TempDir() + if err := hooks.InstallLnkRepo(dir, "/usr/local/bin/lnk"); err != nil { + t.Fatalf("InstallLnkRepo: %v", err) + } + if err := hooks.UninstallLnkRepo(dir); err != nil { + t.Fatalf("UninstallLnkRepo: %v", err) + } + + if hooks.IsInstalled(filepath.Join(dir, ".git", "hooks"), hooks.LnkRepoHook) { + t.Error("expected hook to be uninstalled") + } +} + +func TestUninstall_LeavesForeignHook(t *testing.T) { + dir := t.TempDir() + hookPath := filepath.Join(dir, ".git", "hooks", "post-merge") + if err := os.MkdirAll(filepath.Dir(hookPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(hookPath, []byte("#!/bin/sh\necho foreign\n"), 0o755); err != nil { + t.Fatal(err) + } + + if err := hooks.UninstallLnkRepo(dir); err != nil { + t.Fatalf("UninstallLnkRepo: %v", err) + } + + if _, err := os.Stat(hookPath); err != nil { + t.Error("expected foreign hook to remain") + } +} + +func TestUninstall_Idempotent(t *testing.T) { + dir := t.TempDir() + if err := hooks.UninstallLnkRepo(dir); err != nil { + t.Fatalf("UninstallLnkRepo on missing hooks: %v", err) + } +} diff --git a/internal/lnkerror/error.go b/internal/lnkerror/error.go index 6ac5f2d..c980f9b 100644 --- a/internal/lnkerror/error.go +++ b/internal/lnkerror/error.go @@ -27,6 +27,7 @@ var ( ErrOutsideProject = errors.New("path is outside the project") ErrEmptyPattern = errors.New("pattern is empty") ErrSyncFailed = errors.New("some files failed to sync") + ErrForeignHook = errors.New("existing hook not managed by lnk") ) // Error wraps a sentinel error with optional context for display. diff --git a/service/hooks.go b/service/hooks.go new file mode 100644 index 0000000..1258785 --- /dev/null +++ b/service/hooks.go @@ -0,0 +1,100 @@ +package service + +import ( + "context" + "fmt" + "io" + "os" + "time" + + "github.com/polymorcodeus/lnk/internal/gitboundary" +) + +// hookTimeout limits how long a hook body may block the git operation. +const hookTimeout = 30 * time.Second + +// RunHook executes the named hook on behalf of a git hook script. It never +// returns a non-nil error; failures are written to errOut as warnings so the +// underlying git operation is never blocked. +func (s *Service) RunHook(ctx context.Context, hookName string, args []string, out, errOut io.Writer) error { + switch hookName { + case "post-merge": + return runWithTimeout(ctx, out, errOut, s.runPostMerge) + case "post-checkout": + return runWithTimeout(ctx, out, errOut, s.runPostCheckout) + default: + _, _ = fmt.Fprintf(errOut, "warning: unknown lnk hook %q\n", hookName) + return nil + } +} + +// runWithTimeout runs fn with a bounded timeout and swallows errors after +// printing them to errOut. This ensures a hung or failing hook never blocks +// the git operation that invoked it. +func runWithTimeout(ctx context.Context, out, errOut io.Writer, fn func(context.Context, io.Writer) error) error { + ctx, cancel := context.WithTimeout(ctx, hookTimeout) + defer cancel() + + if err := fn(ctx, out); err != nil { + _, _ = fmt.Fprintf(errOut, "warning: lnk hook: %s\n", err.Error()) + } + return nil +} + +func (s *Service) runPostMerge(ctx context.Context, out io.Writer) error { + info, err := s.RestoreHook(ctx) + if err != nil { + return err + } + if len(info.Restored) == 0 && len(info.Collisions) == 0 { + return nil + } + return printRestoreHook(out, info) +} + +func (s *Service) runPostCheckout(ctx context.Context, out io.Writer) error { + // Git runs hooks with the repo root as the working directory. Resolve it + // explicitly so the hook works even if cwd is a subdirectory. + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + + root, err := gitboundary.ResolveGitRoot(ctx, cwd) + if err != nil { + return err + } + if root == "" { + return nil + } + + // Do not treat the lnk repository as a project repository. + if s.isLnkRepoRoot(root) { + return nil + } + + ps := NewProjectService(s) + info, err := ps.ProjectRestoreHook(ctx, root) + if err != nil { + return err + } + if len(info.Restored) == 0 && len(info.Collisions) == 0 { + return nil + } + return printRestoreHook(out, info) +} + +// printRestoreHook writes hook restore results to w. +func printRestoreHook(w io.Writer, info RestoreInfo) error { + for _, path := range info.Restored { + if _, err := fmt.Fprintf(w, "lnk hook: restored %s\n", path); err != nil { + return err + } + } + for _, path := range info.Collisions { + if _, err := fmt.Fprintf(w, "lnk hook: collision at %s (left untouched)\n", path); err != nil { + return err + } + } + return nil +} diff --git a/service/hooks_test.go b/service/hooks_test.go new file mode 100644 index 0000000..d16c60d --- /dev/null +++ b/service/hooks_test.go @@ -0,0 +1,196 @@ +package service_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/polymorcodeus/lnk/internal/testhelpers" + "github.com/polymorcodeus/lnk/service" +) + +func TestRestoreHook_CreatesMissingAndReportsCollisions(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoPath := svc.RepoPath() + + storagePath, livePath := setupTrackedFile(t, repoPath, home, "common", ".bashrc", "# bashrc") + if err := os.Remove(livePath); err != nil { + t.Fatal(err) + } + + collisionPath := filepath.Join(home, ".vimrc") + testhelpers.MakeFile(t, collisionPath, "# local vimrc") + if err := os.WriteFile(filepath.Join(repoPath, ".lnk.common"), []byte(".bashrc\n.vimrc\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repoPath, "common.lnk", ".vimrc"), []byte("# stored vimrc"), 0o644); err != nil { + t.Fatal(err) + } + + info, err := svc.RestoreHook(context.Background()) + if err != nil { + t.Fatalf("RestoreHook: %v", err) + } + if len(info.Restored) != 1 || info.Restored[0] != ".bashrc" { + t.Errorf("Restored = %v, want [.bashrc]", info.Restored) + } + if len(info.Collisions) != 1 || info.Collisions[0] != ".vimrc" { + t.Errorf("Collisions = %v, want [.vimrc]", info.Collisions) + } + if len(info.BackedUp) != 0 { + t.Errorf("BackedUp = %v, want []", info.BackedUp) + } + + testhelpers.AssertSymlink(t, livePath, storagePath) + if _, err := os.Lstat(collisionPath + ".lnk-backup"); err == nil { + t.Error("expected no .lnk-backup for hook collision") + } + content, err := os.ReadFile(collisionPath) + if err != nil { + t.Fatal(err) + } + if string(content) != "# local vimrc" { + t.Errorf("collision file content = %q, want unchanged", string(content)) + } +} + +func TestRestoreHook_Idempotent(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoPath := svc.RepoPath() + + storagePath, livePath := setupTrackedFile(t, repoPath, home, "common", ".bashrc", "# bashrc") + + info, err := svc.RestoreHook(context.Background()) + if err != nil { + t.Fatalf("RestoreHook: %v", err) + } + if len(info.Restored) != 0 { + t.Errorf("Restored = %v, want []", info.Restored) + } + if len(info.Collisions) != 0 { + t.Errorf("Collisions = %v, want []", info.Collisions) + } + + testhelpers.AssertSymlink(t, livePath, storagePath) +} + +func TestRunHook_PostCheckout_CreatesMissingSymlinks(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + t.Chdir(repoDir) + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".todo/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + + todoLive := filepath.Join(repoDir, ".todo", "a.md") + testhelpers.MakeFile(t, todoLive, "- todo\n") + + result, err := ps.ProjectPush(context.Background(), repoDir, false) + if err != nil { + t.Fatalf("ProjectPush: %v", err) + } + + todoStorage := filepath.Join(svc.RepoPath(), "projects", result.ProjectID, ".todo", "a.md") + if err := os.Remove(todoLive); err != nil { + t.Fatal(err) + } + + var out, errOut bytes.Buffer + if err := svc.RunHook(context.Background(), "post-checkout", []string{"0", "1", "1"}, &out, &errOut); err != nil { + t.Fatalf("RunHook: %v", err) + } + + if out.String() == "" { + t.Error("expected hook output for restored symlink") + } + if errOut.String() != "" { + t.Errorf("unexpected stderr: %s", errOut.String()) + } + testhelpers.AssertSymlink(t, todoLive, todoStorage) +} + +func TestRunHook_PostCheckout_Idempotent(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + t.Chdir(repoDir) + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".todo/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + + todoLive := filepath.Join(repoDir, ".todo", "a.md") + testhelpers.MakeFile(t, todoLive, "- todo\n") + + if _, err := ps.ProjectPush(context.Background(), repoDir, false); err != nil { + t.Fatalf("ProjectPush: %v", err) + } + + var out, errOut bytes.Buffer + if err := svc.RunHook(context.Background(), "post-checkout", []string{"0", "1", "1"}, &out, &errOut); err != nil { + t.Fatalf("RunHook: %v", err) + } + + if out.String() != "" { + t.Errorf("expected no output for idempotent hook, got %q", out.String()) + } + if errOut.String() != "" { + t.Errorf("unexpected stderr: %s", errOut.String()) + } +} + +func TestRunHook_PostCheckout_ReportsCollisions(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoDir := filepath.Join(home, "repos", "hermes") + testhelpers.MakeDir(t, repoDir) + initProjectRepo(t, repoDir) + t.Chdir(repoDir) + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".todo/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + + todoLive := filepath.Join(repoDir, ".todo", "a.md") + testhelpers.MakeFile(t, todoLive, "- todo\n") + + if _, err := ps.ProjectPush(context.Background(), repoDir, false); err != nil { + t.Fatalf("ProjectPush: %v", err) + } + if err := os.Remove(todoLive); err != nil { + t.Fatal(err) + } + testhelpers.MakeFile(t, todoLive, "local todo\n") + + var out, errOut bytes.Buffer + if err := svc.RunHook(context.Background(), "post-checkout", []string{"0", "1", "1"}, &out, &errOut); err != nil { + t.Fatalf("RunHook: %v", err) + } + + if out.String() == "" { + t.Error("expected hook output for collision") + } + if _, err := os.Lstat(todoLive + ".lnk-backup"); err == nil { + t.Error("expected no .lnk-backup for hook collision") + } +} + +func TestRunHook_UnknownHook(t *testing.T) { + svc, _ := testhelpers.TestHome(t) + + var out, errOut bytes.Buffer + if err := svc.RunHook(context.Background(), "pre-commit", nil, &out, &errOut); err != nil { + t.Fatalf("RunHook should never return error: %v", err) + } + if errOut.String() == "" { + t.Error("expected warning on stderr for unknown hook") + } +} diff --git a/service/project.go b/service/project.go index 937eb4d..0ae385f 100644 --- a/service/project.go +++ b/service/project.go @@ -248,31 +248,13 @@ func (ps *ProjectService) resolveProjectRoot(ctx context.Context, dir string) (s return "", lnkerror.WithPathAndSuggestion(lnkerror.ErrOutsideGitRepo, abs, "use 'lnk add' for host/common scope") } - if ps.isLnkRepoRoot(root) { + if ps.svc.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) { @@ -1391,9 +1373,12 @@ func (ps *ProjectService) ProjectRestore(ctx context.Context, projectRoot string if liveExists { if liveIsSymlink { + if isManagedSymlink(livePath, path) { + return nil + } if !dryRun { if err := os.Remove(livePath); err != nil { - return fmt.Errorf("replace symlink %s: %w", livePath, err) + return fmt.Errorf("replace stale symlink %s: %w", livePath, err) } } } else { @@ -1430,6 +1415,115 @@ func (ps *ProjectService) ProjectRestore(ctx context.Context, projectRoot string return info, nil } +// ProjectRestoreHook is the collision-safe variant of ProjectRestore used by +// the post-checkout git hook. It creates missing symlinks, skips symlinks +// that already point to the correct storage target, replaces symlinks that +// point elsewhere, and reports real-file collisions without backing them up. +// It never returns an error for a per-file collision; collisions are collected +// in RestoreInfo.Collisions and reported by the caller. +func (ps *ProjectService) ProjectRestoreHook(ctx context.Context, projectRoot string) (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{}, nil + } + + 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 + } + + match, err := patterns.Match(effective, rel) + if err != nil { + return err + } + if !match { + 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 _, ok := tracked[rel]; ok && !liveIsSymlink { + info.Collisions = append(info.Collisions, rel) + return nil + } + + if liveExists { + if liveIsSymlink { + if isManagedSymlink(livePath, path) { + return nil + } + if err := os.Remove(livePath); err != nil { + return fmt.Errorf("replace stale symlink %s: %w", livePath, err) + } + } else { + info.Collisions = append(info.Collisions, rel) + return nil + } + } + + info.Restored = append(info.Restored, rel) + 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 { diff --git a/service/project_test.go b/service/project_test.go index e95d47c..796abd4 100644 --- a/service/project_test.go +++ b/service/project_test.go @@ -1460,6 +1460,142 @@ func TestProjectRestore_GatesOnPatterns(t *testing.T) { testhelpers.AssertSymlink(t, notesLive, filepath.Join(storageDir, "notes.md")) } +func TestProjectRestore_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.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") + testhelpers.AssertSymlink(t, liveFile, storageFile) + + 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 [] for already-correct symlink", info.Restored) + } + if len(info.BackedUp) != 0 { + t.Errorf("BackedUp = %v, want []", info.BackedUp) + } + + testhelpers.AssertSymlink(t, liveFile, storageFile) +} + +func TestProjectRestoreHook_CreatesMissingAndReportsCollisions(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) + } + if _, _, err := ps.ProjectAddPattern(context.Background(), repoDir, ".todo/**"); err != nil { + t.Fatalf("add pattern: %v", err) + } + + rulesLive := filepath.Join(repoDir, ".cursor", "rules.md") + todoLive := filepath.Join(repoDir, ".todo", "a.md") + testhelpers.MakeFile(t, rulesLive, "# rules\n") + testhelpers.MakeFile(t, todoLive, "- todo\n") + + result, err := ps.ProjectPush(context.Background(), repoDir, false) + if err != nil { + t.Fatalf("ProjectPush: %v", err) + } + + todoStorage := filepath.Join(svc.RepoPath(), "projects", result.ProjectID, ".todo", "a.md") + + // Simulate fresh clone: remove the symlinks. + if err := os.Remove(rulesLive); err != nil { + t.Fatalf("remove rules symlink: %v", err) + } + if err := os.Remove(todoLive); err != nil { + t.Fatalf("remove todo symlink: %v", err) + } + + // Put a real file at one target to create a collision. + testhelpers.MakeFile(t, rulesLive, "local rules\n") + + info, err := ps.ProjectRestoreHook(context.Background(), repoDir) + if err != nil { + t.Fatalf("ProjectRestoreHook: %v", err) + } + if len(info.Restored) != 1 || info.Restored[0] != ".todo/a.md" { + t.Errorf("Restored = %v, want [.todo/a.md]", info.Restored) + } + if len(info.Collisions) != 1 || info.Collisions[0] != ".cursor/rules.md" { + t.Errorf("Collisions = %v, want [.cursor/rules.md]", info.Collisions) + } + if len(info.BackedUp) != 0 { + t.Errorf("BackedUp = %v, want []", info.BackedUp) + } + + if _, err := os.Lstat(rulesLive + ".lnk-backup"); err == nil { + t.Error("expected no .lnk-backup for hook collision") + } + content, err := os.ReadFile(rulesLive) + if err != nil { + t.Fatal(err) + } + if string(content) != "local rules\n" { + t.Errorf("collision file content = %q, want unchanged", string(content)) + } + testhelpers.AssertSymlink(t, todoLive, todoStorage) +} + +func TestProjectRestoreHook_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.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") + testhelpers.AssertSymlink(t, liveFile, storageFile) + + info, err := ps.ProjectRestoreHook(context.Background(), repoDir) + if err != nil { + t.Fatalf("ProjectRestoreHook: %v", err) + } + if len(info.Restored) != 0 { + t.Errorf("Restored = %v, want [] for already-correct symlink", info.Restored) + } + if len(info.Collisions) != 0 { + t.Errorf("Collisions = %v, want []", info.Collisions) + } + + testhelpers.AssertSymlink(t, liveFile, storageFile) +} + // ---------- Global patterns, discovery, and doctor ---------- func TestProjectAddGlobalPattern(t *testing.T) { diff --git a/service/restore.go b/service/restore.go index c994943..7335804 100644 --- a/service/restore.go +++ b/service/restore.go @@ -72,3 +72,50 @@ func (s *Service) Restore(ctx context.Context, host string, dryRun bool) (Restor } return info, nil } + +// RestoreHook is the collision-safe variant of Restore used by the +// post-merge git hook in the lnk repo. It creates missing symlinks, skips +// symlinks that already resolve correctly, replaces stale symlinks, and +// reports real-file collisions without backing them up. +func (s *Service) RestoreHook(ctx context.Context) (RestoreInfo, error) { + if err := s.requireGitRepo(); err != nil { + return RestoreInfo{}, err + } + + host := NormalizeHost("") + items, err := s.profileItems(host) + if err != nil { + return RestoreInfo{}, err + } + + info := RestoreInfo{} + fs := &fspkg.FileSystem{} + for _, item := range items { + if _, err := os.Stat(item.RepoPath); errors.Is(err, os.ErrNotExist) { + continue + } + if isManagedSymlink(item.LivePath, item.RepoPath) { + continue + } + + currentInfo, err := os.Lstat(item.LivePath) + if err == nil { + if currentInfo.Mode()&os.ModeSymlink == 0 { + info.Collisions = append(info.Collisions, item.RelativePath) + continue + } + if err := os.Remove(item.LivePath); err != nil { + return RestoreInfo{}, fmt.Errorf("remove stale symlink %s: %w", item.LivePath, err) + } + } + + info.Restored = append(info.Restored, item.RelativePath) + if err := os.MkdirAll(filepath.Dir(item.LivePath), 0o755); err != nil { + return RestoreInfo{}, fmt.Errorf("create live parent directory: %w", err) + } + if err := fs.CreateSymlink(item.RepoPath, item.LivePath); err != nil { + return RestoreInfo{}, err + } + } + return info, nil +} diff --git a/service/service.go b/service/service.go index c955b28..227f16f 100644 --- a/service/service.go +++ b/service/service.go @@ -76,6 +76,10 @@ type ListResult struct { type RestoreInfo struct { Restored []string BackedUp []string + // Collisions lists project-scope paths that could not be restored because + // a real file occupies the target. Used by hook mode, which reports + // collisions instead of backing them up. + Collisions []string // SkippedTracked lists project-scope paths left untouched because the // project's own git index tracks them (requires force to manage). SkippedTracked []string @@ -462,23 +466,14 @@ func (s *Service) hasLnkMarker() bool { return err == nil } -// IsLnkRepository checks if the repository appears to be managed by lnk -func (s *Service) IsLnkRepository() bool { - if !s.git.IsGitRepository() { - return false - } - - return s.hasLnkMarker() -} - -// isLnkRepoRoot reports whether root is the configured lnk repository or -// carries the .lnkrepo marker. This prevents implicit project detection from -// treating the lnk repo itself as a project. +// 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 (s *Service) isLnkRepoRoot(root string) bool { if _, err := os.Stat(filepath.Join(root, repoMarkerFile)); err == nil { return true } - repoPath, err := filepath.EvalSymlinks(s.repoPath) + repoPath, err := filepath.EvalSymlinks(s.RepoPath()) if err != nil { return false } @@ -488,3 +483,12 @@ func (s *Service) isLnkRepoRoot(root string) bool { } return repoPath == canonicalRoot } + +// IsLnkRepository checks if the repository appears to be managed by lnk +func (s *Service) IsLnkRepository() bool { + if !s.git.IsGitRepository() { + return false + } + + return s.hasLnkMarker() +} diff --git a/tests/integration/hooks_test.go b/tests/integration/hooks_test.go new file mode 100644 index 0000000..5d7b768 --- /dev/null +++ b/tests/integration/hooks_test.go @@ -0,0 +1,222 @@ +//go:build integration + +package integration + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/polymorcodeus/lnk/internal/hooks" + "github.com/polymorcodeus/lnk/internal/testhelpers" + "github.com/polymorcodeus/lnk/service" +) + +// TestIntegration_HookPostCheckout_RestoresMissingSymlinks verifies that the +// installed post-checkout hook recreates project-scope symlinks after a git +// checkout without backing up existing files. +func buildLnkBinary(t *testing.T) string { + t.Helper() + lnkPath := filepath.Join(t.TempDir(), "lnk") + repoRoot, err := filepath.Abs("../..") + if err != nil { + t.Fatalf("resolve repo root: %v", err) + } + cmd := exec.Command("go", "build", "-o", lnkPath, ".") + cmd.Dir = repoRoot + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build lnk binary: %v\n%s", err, out) + } + return lnkPath +} + +func TestIntegration_HookPostCheckout_RestoresMissingSymlinks(t *testing.T) { + lnkBinary := buildLnkBinary(t) + + svc, home := testhelpers.TestHome(t) + projectDir := filepath.Join(home, "project") + testhelpers.MakeDir(t, projectDir) + testhelpers.InitGitRepo(t, projectDir) + + if out, err := exec.Command("git", "-C", projectDir, "remote", "add", "origin", "git@github.com:User/Repo.git").CombinedOutput(); err != nil { + t.Fatalf("git remote add: %v\n%s", err, out) + } + readme := filepath.Join(projectDir, "README.md") + testhelpers.MakeFile(t, readme, "# repo\n") + if out, err := exec.Command("git", "-C", projectDir, "add", ".").CombinedOutput(); err != nil { + t.Fatalf("git add: %v\n%s", err, out) + } + if out, err := exec.Command("git", "-C", projectDir, "commit", "-m", "init").CombinedOutput(); err != nil { + t.Fatalf("git commit: %v\n%s", err, out) + } + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), projectDir, ".todo/**"); err != nil { + t.Fatalf("ProjectAddPattern: %v", err) + } + + todoLive := filepath.Join(projectDir, ".todo", "a.md") + testhelpers.MakeFile(t, todoLive, "- todo\n") + + result, err := ps.ProjectPush(context.Background(), projectDir, false) + if err != nil { + t.Fatalf("ProjectPush: %v", err) + } + + todoStorage := filepath.Join(svc.RepoPath(), "projects", result.ProjectID, ".todo", "a.md") + testhelpers.AssertSymlink(t, todoLive, todoStorage) + + // Install the post-checkout hook using a real lnk binary. + if err := hooks.InstallProject(projectDir, lnkBinary); err != nil { + t.Fatalf("InstallProject: %v", err) + } + + // Create a branch so we can switch back to main and trigger the hook. + if out, err := exec.Command("git", "-C", projectDir, "checkout", "-b", "feature").CombinedOutput(); err != nil { + t.Fatalf("git checkout -b feature: %v\n%s", err, out) + } + + // Remove the symlink to simulate a fresh clone / checkout state. + if err := os.Remove(todoLive); err != nil { + t.Fatal(err) + } + + // Trigger the hook by switching back to main. + out, err := exec.Command("git", "-C", projectDir, "checkout", "main").CombinedOutput() + if err != nil { + t.Fatalf("git checkout main: %v\n%s", err, out) + } + + // The hook should have recreated the symlink. + testhelpers.AssertSymlink(t, todoLive, todoStorage) + if _, err := os.Lstat(todoLive + ".lnk-backup"); err == nil { + t.Error("expected no .lnk-backup from hook restore") + } +} + +// TestIntegration_HookPostCheckout_ReportsCollision verifies that the +// post-checkout hook leaves a real file in place and reports the collision. +func TestIntegration_HookPostCheckout_ReportsCollision(t *testing.T) { + lnkBinary := buildLnkBinary(t) + + svc, home := testhelpers.TestHome(t) + projectDir := filepath.Join(home, "project") + testhelpers.MakeDir(t, projectDir) + testhelpers.InitGitRepo(t, projectDir) + + if out, err := exec.Command("git", "-C", projectDir, "remote", "add", "origin", "git@github.com:User/Repo.git").CombinedOutput(); err != nil { + t.Fatalf("git remote add: %v\n%s", err, out) + } + readme := filepath.Join(projectDir, "README.md") + testhelpers.MakeFile(t, readme, "# repo\n") + if out, err := exec.Command("git", "-C", projectDir, "add", ".").CombinedOutput(); err != nil { + t.Fatalf("git add: %v\n%s", err, out) + } + if out, err := exec.Command("git", "-C", projectDir, "commit", "-m", "init").CombinedOutput(); err != nil { + t.Fatalf("git commit: %v\n%s", err, out) + } + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), projectDir, ".todo/**"); err != nil { + t.Fatalf("ProjectAddPattern: %v", err) + } + + todoLive := filepath.Join(projectDir, ".todo", "a.md") + testhelpers.MakeFile(t, todoLive, "- todo\n") + + if _, err := ps.ProjectPush(context.Background(), projectDir, false); err != nil { + t.Fatalf("ProjectPush: %v", err) + } + + // Replace the symlink with a real file to create a collision. + if err := os.Remove(todoLive); err != nil { + t.Fatal(err) + } + testhelpers.MakeFile(t, todoLive, "local todo\n") + + if err := hooks.InstallProject(projectDir, lnkBinary); err != nil { + t.Fatalf("InstallProject: %v", err) + } + + if out, err := exec.Command("git", "-C", projectDir, "checkout", "-b", "feature").CombinedOutput(); err != nil { + t.Fatalf("git checkout -b feature: %v\n%s", err, out) + } + + out, err := exec.Command("git", "-C", projectDir, "checkout", "main").CombinedOutput() + if err != nil { + t.Fatalf("git checkout main: %v\n%s", err, out) + } + if !strings.Contains(string(out), "collision") { + t.Errorf("expected collision message in hook output, got:\n%s", out) + } + + content, err := os.ReadFile(todoLive) + if err != nil { + t.Fatal(err) + } + if string(content) != "local todo\n" { + t.Errorf("collision file was modified: %q", string(content)) + } + if _, err := os.Lstat(todoLive + ".lnk-backup"); err == nil { + t.Error("expected no .lnk-backup from hook collision") + } +} + +// TestIntegration_HookPostCheckout_Idempotent verifies that a branch switch +// with already-correct symlinks is a no-op. +func TestIntegration_HookPostCheckout_Idempotent(t *testing.T) { + lnkBinary := buildLnkBinary(t) + + svc, home := testhelpers.TestHome(t) + projectDir := filepath.Join(home, "project") + testhelpers.MakeDir(t, projectDir) + testhelpers.InitGitRepo(t, projectDir) + + if out, err := exec.Command("git", "-C", projectDir, "remote", "add", "origin", "git@github.com:User/Repo.git").CombinedOutput(); err != nil { + t.Fatalf("git remote add: %v\n%s", err, out) + } + readme := filepath.Join(projectDir, "README.md") + testhelpers.MakeFile(t, readme, "# repo\n") + if out, err := exec.Command("git", "-C", projectDir, "add", ".").CombinedOutput(); err != nil { + t.Fatalf("git add: %v\n%s", err, out) + } + if out, err := exec.Command("git", "-C", projectDir, "commit", "-m", "init").CombinedOutput(); err != nil { + t.Fatalf("git commit: %v\n%s", err, out) + } + + ps := service.NewProjectService(svc) + if _, _, err := ps.ProjectAddPattern(context.Background(), projectDir, ".todo/**"); err != nil { + t.Fatalf("ProjectAddPattern: %v", err) + } + + todoLive := filepath.Join(projectDir, ".todo", "a.md") + testhelpers.MakeFile(t, todoLive, "- todo\n") + + result, err := ps.ProjectPush(context.Background(), projectDir, false) + if err != nil { + t.Fatalf("ProjectPush: %v", err) + } + + todoStorage := filepath.Join(svc.RepoPath(), "projects", result.ProjectID, ".todo", "a.md") + + if err := hooks.InstallProject(projectDir, lnkBinary); err != nil { + t.Fatalf("InstallProject: %v", err) + } + + if out, err := exec.Command("git", "-C", projectDir, "checkout", "-b", "feature").CombinedOutput(); err != nil { + t.Fatalf("git checkout -b feature: %v\n%s", err, out) + } + + out, err := exec.Command("git", "-C", projectDir, "checkout", "main").CombinedOutput() + if err != nil { + t.Fatalf("git checkout main: %v\n%s", err, out) + } + if strings.Contains(string(out), "restored") { + t.Errorf("expected no restore output for idempotent hook, got:\n%s", out) + } + + testhelpers.AssertSymlink(t, todoLive, todoStorage) +}