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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,3 +13,6 @@ coverage.html
# Go workspace
go.work
go.work.sum

# Machine-local project checkout cache
.lnkprojectcache
10 changes: 8 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,7 +161,7 @@ lnk doctor --fix --prune-empty # also remove empty host scopes and pr
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.
`lnk doctor` checks project scope as well as host/common scope: it reports orphaned project storage, broken project symlinks, and missing project checkouts using the machine-local `.lnkprojectcache`. 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. 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.

Expand DownExpand Up@@ -200,7 +200,9 @@ lnk project list # show effective global + local patter
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 --all # reconcile every stored project
lnk project sync --prune-deletions # also drop storage for files deleted locally
lnk project cache --scan ~/code # discover local checkouts and update .lnkprojectcache
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
Expand All@@ -223,6 +225,8 @@ lnk project untrack --global AGENTS.md # remove the global pattern

Matched files are stored under `projects/<normalized-origin>/<path>/` 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 `<path>.lnk-backup` during restore, just like host/common scope restores.

Project checkouts are tracked in a machine-local `.lnkprojectcache` file inside the lnk repo. The cache is updated automatically on `project push` and `project sync`, and is used by `project sync --all` and `lnk doctor` to find local projects without scanning `$HOME`. It is gitignored so absolute paths are not synced across machines.

`lnk update` and `lnk restore` automatically detect project scope when they run inside a git repo that contains a `.lnkinclude` file. The project scope is restored alongside the common and host scopes, and a `(project scope: <id>)` message is printed to stderr. Use the global `--no-project` flag to skip automatic detection.

### Notes and edge cases
Expand All@@ -232,6 +236,7 @@ Matched files are stored under `projects/<normalized-origin>/<path>/` in your ln
- **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`.
- **`.lnkprojectcache` is machine-local.** The cache is maintained automatically by `project push` and `project sync`. Use `project cache --scan <dir>` to populate or repair it on a new machine or after moving checkouts.

### Hooks

Expand DownExpand Up@@ -283,7 +288,8 @@ man man/lnk-project-push.1 # read a generated page
| `project list` | Show effective project patterns |
| `project untrack [--keep] <pattern>` | 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 sync [--all] [--dry-run] [--prune-deletions] [--force]` | Reconcile patterns, live files, and storage |
| `project cache --scan <dir>` | Discover local checkouts and update `.lnkprojectcache` |
| `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 |
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
v2.2.0
v2.3.0
155 changes: 130 additions & 25 deletions cmd/root.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -177,6 +177,7 @@ func newProjectCmd(repoFlag *string) *cobra.Command {
cmd.AddCommand(newProjectUntrackCmd(repoFlag))
cmd.AddCommand(newProjectPushCmd(repoFlag))
cmd.AddCommand(newProjectSyncCmd(repoFlag))
cmd.AddCommand(newProjectCacheCmd(repoFlag))
cmd.AddCommand(newProjectRestoreCmd(repoFlag))
cmd.AddCommand(newProjectPullCmd(repoFlag))
cmd.AddCommand(newProjectRemoveCmd(repoFlag))
Expand DownExpand Up@@ -428,65 +429,169 @@ func newProjectSyncCmd(repoFlag *string) *cobra.Command {
var dryRun bool
var pruneDeletions bool
var force bool
var all bool

cmd := &cobra.Command{
Use: "sync [--dry-run] [--prune-deletions] [--force]",
Use: "sync [--all] [--dry-run] [--prune-deletions] [--force]",
Short: "Reconcile patterns, live files, and project storage",
RunE: func(cmd *cobra.Command, args []string) error {
ps := service.NewProjectService(svc(repoFlag))

if all {
result, err := ps.ProjectSyncAll(cmd.Context(), dryRun, pruneDeletions, force)
if err != nil {
return err
}
return printProjectSyncAll(cmd.OutOrStdout(), cmd.ErrOrStderr(), result, dryRun)
}

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 printProjectSync(cmd.OutOrStdout(), cmd.ErrOrStderr(), result, dryRun)
},
}

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")
cmd.Flags().BoolVar(&all, "all", false, "reconcile every stored project using .lnkprojectcache")
return cmd
}

// newProjectCacheCmd returns the "project cache" subcommand.
func newProjectCacheCmd(repoFlag *string) *cobra.Command {
var scanRoots []string

cmd := &cobra.Command{
Use: "cache --scan path [--scan path]...",
Short: "Discover local project checkouts and update .lnkprojectcache",
RunE: func(cmd *cobra.Command, args []string) error {
ps := service.NewProjectService(svc(repoFlag))
result, err := ps.ProjectCacheDiscover(cmd.Context(), scanRoots)
if err != nil {
return err
}
if err := printSyncSection(w, dryRunPrefix(dryRun, "Backed up", "Would back up"), result.BackedUp, "conflicting file(s)"); err != nil {
return err

w := cmd.OutOrStdout()
if len(result.Discovered) > 0 {
if _, err := fmt.Fprintf(w, "Discovered %d project(s):\n", len(result.Discovered)); err != nil {
return err
}
for _, id := range result.Discovered {
if _, err := fmt.Fprintf(w, " %s\n", id); 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.Validated) > 0 {
if _, err := fmt.Fprintf(w, "Validated %d project(s):\n", len(result.Validated)); err != nil {
return err
}
for _, id := range result.Validated {
if _, err := fmt.Fprintf(w, " %s\n", id); 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 {
if len(result.Missing) > 0 {
if _, err := fmt.Fprintf(w, "Marked %d project(s) as missing:\n", len(result.Missing)); err != nil {
return err
}
for _, path := range result.Deletions {
if _, err := fmt.Fprintf(w, " %s\n", path); err != nil {
for _, id := range result.Missing {
if _, err := fmt.Fprintf(w, " %s\n", id); 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 {
if len(result.Removed) > 0 {
if _, err := fmt.Fprintf(w, "Removed %d stale cache entry(ies):\n", len(result.Removed)); err != nil {
return err
}
for _, id := range result.Removed {
if _, err := fmt.Fprintf(w, " %s\n", id); 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
if len(result.Discovered)+len(result.Validated)+len(result.Missing)+len(result.Removed) == 0 {
_, err = fmt.Fprintln(w, "No changes to project cache")
}
return nil
return err
},
}

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")
cmd.Flags().StringArrayVar(&scanRoots, "scan", nil, "directory to scan for local project checkouts (repeatable)")
_ = cmd.MarkFlagRequired("scan")
return cmd
}

// printProjectSync writes the output for a single project sync result.
func printProjectSync(w, errW io.Writer, result service.ProjectSyncResult, dryRun bool) error {
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(errW, "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
}

// printProjectSyncAll writes the output for `lnk project sync --all`.
func printProjectSyncAll(w, errW io.Writer, result service.ProjectSyncAllResult, dryRun bool) error {
for _, res := range result.Results {
if _, err := fmt.Fprintf(w, "# %s\n", res.ProjectID); err != nil {
return err
}
if err := printProjectSync(w, errW, res, dryRun); err != nil {
return err
}
}
for _, id := range result.Unavailable {
if _, err := fmt.Fprintf(errW, "warning: skipping %s: local checkout not found in scan roots\n", id); err != nil {
return err
}
}
if len(result.Results) == 0 && len(result.Unavailable) == 0 {
_, err := fmt.Fprintln(w, "No stored projects")
return err
}
return nil
}

// 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 {
Expand Down
Loading
Loading