Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<path>.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 `<path>.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

Expand DownExpand Up@@ -233,6 +233,21 @@ Matched files are stored under `projects/<normalized-origin>/<path>/` 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 <hook-name>`, 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.
Expand DownExpand Up@@ -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 <hook-name> [args...]` | Entry point used by installed git hook scripts |

## Global Options

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
v2.1.0
v2.2.0
127 changes: 127 additions & 0 deletions cmd/hooks.go
Original file line numberDiff line numberDiff line change
@@ -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 <hook-name> [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
}
11 changes: 11 additions & 0 deletions cmd/root.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
}
Expand DownExpand Up@@ -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
}
Expand Down
105 changes: 105 additions & 0 deletions internal/hooks/hooks.go
Original file line numberDiff line numberDiff line change
@@ -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
}
Loading
Loading