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
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ go.work.sum
# Build artifacts
dist/
bin/
man/

# IDE and editor files
.vscode/
Expand Down
3 changes: 3 additions & 0 deletions .goreleaser.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand DownExpand Up@@ -44,6 +46,7 @@ archives:
files:
- README.md
- LICENSE
- man/
builds_info:
group: root
owner: root
Expand Down
10 changes: 9 additions & 1 deletion Makefile
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand All@@ -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"
Expand DownExpand Up@@ -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)"
Expand DownExpand Up@@ -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)"

Expand Down
66 changes: 62 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -183,6 +183,55 @@ lnk clone <url> --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/<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.

### 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 |
Expand All@@ -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 <pattern...>` | Add patterns to the project's `.lnkinclude` |
| `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 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

Expand All@@ -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

Expand Down
16 changes: 13 additions & 3 deletions TESTING.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. | `<host>.lnk/` | `.lnk.<host>` |
| Project scope | N/A (uses `--dir`) | `projects/<normalized-origin>/` | 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 `<path>.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 `<path>.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

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
v1.5.0
v2.0.0
Loading
Loading