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
19 changes: 15 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,10 +157,12 @@ lnk list --all # all scopes
```bash
lnk doctor # audit repo and profile health
lnk doctor --fix # apply safe automatic fixes
lnk doctor --fix --prune-empty # also remove empty host scopes
lnk doctor --fix --prune-empty # also remove empty host scopes and project storage
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.

### Format migration
Expand DownExpand Up@@ -207,6 +209,12 @@ lnk project untrack --keep .crush/** # remove a pattern but leave files man
lnk project remove # stop managing the project, restore all files
lnk project forget # stop managing the project, keep stored files

# implicit detection in update/restore
lnk update # also restores any detected project scope
lnk restore # also restores any detected project scope
lnk update --no-project # skip automatic project-scope detection
lnk restore --no-project # skip automatic project-scope detection

# 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
Expand All@@ -215,6 +223,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.

`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

- **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.
Expand DownExpand Up@@ -248,9 +258,9 @@ man man/lnk-project-push.1 # read a generated page
| `commit [-m message]` | Stage all changes and commit |
| `push` | Push existing commits |
| `pull` | Pull repo changes |
| `restore [--host H] [--dry-run]` | Restore symlinks without pulling |
| `update [--host H]` | Pull and restore the effective profile |
| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health |
| `restore [--host H] [--dry-run] [--no-project]` | Restore symlinks without pulling (auto-detects project scope) |
| `update [--host H] [--no-project]` | Pull and restore the effective profile (auto-detects project scope) |
| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health, including project scope |
| `format [--v1 \| --v2]` | Migrate repo format |
| `bootstrap` | Run bootstrap.sh explicitly |
| `project init` | Activate project scope in the current git repo |
Expand All@@ -271,6 +281,7 @@ Available with all commands:
| Option | Default | What it does |
| --- | --- | --- |
| `--repo <path>` | `~/.config/lnk` | Path to the lnk repository |
| `--no-project` | `false` | Skip automatic project-scope detection |

## Acknowledgements

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
v2.0.0
v2.1.0
25 changes: 23 additions & 2 deletions cmd/root.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ func NewRootCommand() *cobra.Command {
}

rootCmd.PersistentFlags().StringVar(&repoPath, "repo", "", "path to the lnk repository")
rootCmd.PersistentFlags().Bool("no-project", false, "skip automatic project scope detection")

rootCmd.AddCommand(newInitCmd(&repoPath))
rootCmd.AddCommand(newCloneCmd(&repoPath))
Expand DownExpand Up@@ -913,7 +914,8 @@ func newRestoreCmd(repoFlag *string) *cobra.Command {
Short: "Restore the effective machine profile",
RunE: func(cmd *cobra.Command, args []string) error {
app := svc(repoFlag)
info, err := app.Restore(cmd.Context(), host, dryRun)
noProject, _ := cmd.Flags().GetBool("no-project")
info, err := app.RestoreWithProject(cmd.Context(), host, noProject, dryRun)
if err != nil {
return err
}
Expand All@@ -935,7 +937,8 @@ func newUpdateCmd(repoFlag *string) *cobra.Command {
Short: "Pull repo changes and restore the effective machine profile",
RunE: func(cmd *cobra.Command, args []string) error {
app := svc(repoFlag)
info, err := app.Update(cmd.Context(), host)
noProject, _ := cmd.Flags().GetBool("no-project")
info, err := app.UpdateWithProject(cmd.Context(), host, noProject)
if err != nil {
return err
}
Expand DownExpand Up@@ -1141,6 +1144,24 @@ func printDoctor(w io.Writer, report service.DoctorReport) error {
}
}
}
if len(report.ProjectIssues) > 0 {
if _, err := fmt.Fprintln(w, "Project issues:"); err != nil {
return err
}
for _, issue := range report.ProjectIssues {
if _, err := fmt.Fprintf(w, " [%s] %s: %s", issue.Severity, issue.ProjectID, issue.Issue); err != nil {
return err
}
if issue.Suggestion != "" {
if _, err := fmt.Fprintf(w, " -> %s", issue.Suggestion); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
}
if len(report.PrunedProjects) > 0 {
if _, err := fmt.Fprintln(w, "Pruned empty project storage:"); err != nil {
return err
Expand Down
17 changes: 16 additions & 1 deletion service/doctor.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,14 +61,23 @@ type DoctorReport struct {
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
ProjectIssues []ProjectIssue
}

// ProjectIssue captures a project-scope health finding for doctor.
type ProjectIssue struct {
ProjectID string
Issue string
Severity string // "error" or "warning"
Suggestion string
}

// HasIssues reports whether the doctor found actionable issues.
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 {
if len(r.UnmarkedProjects) > 0 || len(r.EmptyProjects) > 0 || len(r.ProjectIssues) > 0 {
return true
}
for _, result := range r.ScopeResults {
Expand DownExpand Up@@ -182,6 +191,12 @@ func (s *Service) doctorScan(ctx context.Context, host string, all bool) (Doctor
report.UnmarkedProjects = unmarked
report.EmptyProjects = emptyProjects

projectIssues, err := s.scanProjectIssues(ctx)
if err != nil {
return DoctorReport{}, err
}
report.ProjectIssues = projectIssues

return report, nil
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Add implicit project-scope detection and doctor health checks by polymorcodeus · Pull Request #25 · polymorcodeus/lnk · GitHub
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
19 changes: 15 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,10 +157,12 @@ lnk list --all # all scopes
```bash
lnk doctor # audit repo and profile health
lnk doctor --fix # apply safe automatic fixes
lnk doctor --fix --prune-empty # also remove empty host scopes
lnk doctor --fix --prune-empty # also remove empty host scopes and project storage
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.

### Format migration
Expand DownExpand Up@@ -207,6 +209,12 @@ lnk project untrack --keep .crush/** # remove a pattern but leave files man
lnk project remove # stop managing the project, restore all files
lnk project forget # stop managing the project, keep stored files

# implicit detection in update/restore
lnk update # also restores any detected project scope
lnk restore # also restores any detected project scope
lnk update --no-project # skip automatic project-scope detection
lnk restore --no-project # skip automatic project-scope detection

# 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
Expand All@@ -215,6 +223,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.

`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

- **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.
Expand DownExpand Up@@ -248,9 +258,9 @@ man man/lnk-project-push.1 # read a generated page
| `commit [-m message]` | Stage all changes and commit |
| `push` | Push existing commits |
| `pull` | Pull repo changes |
| `restore [--host H] [--dry-run]` | Restore symlinks without pulling |
| `update [--host H]` | Pull and restore the effective profile |
| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health |
| `restore [--host H] [--dry-run] [--no-project]` | Restore symlinks without pulling (auto-detects project scope) |
| `update [--host H] [--no-project]` | Pull and restore the effective profile (auto-detects project scope) |
| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health, including project scope |
| `format [--v1 \| --v2]` | Migrate repo format |
| `bootstrap` | Run bootstrap.sh explicitly |
| `project init` | Activate project scope in the current git repo |
Expand All@@ -271,6 +281,7 @@ Available with all commands:
| Option | Default | What it does |
| --- | --- | --- |
| `--repo <path>` | `~/.config/lnk` | Path to the lnk repository |
| `--no-project` | `false` | Skip automatic project-scope detection |

## Acknowledgements

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
v2.0.0
v2.1.0
25 changes: 23 additions & 2 deletions cmd/root.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ func NewRootCommand() *cobra.Command {
}

rootCmd.PersistentFlags().StringVar(&repoPath, "repo", "", "path to the lnk repository")
rootCmd.PersistentFlags().Bool("no-project", false, "skip automatic project scope detection")

rootCmd.AddCommand(newInitCmd(&repoPath))
rootCmd.AddCommand(newCloneCmd(&repoPath))
Expand DownExpand Up@@ -913,7 +914,8 @@ func newRestoreCmd(repoFlag *string) *cobra.Command {
Short: "Restore the effective machine profile",
RunE: func(cmd *cobra.Command, args []string) error {
app := svc(repoFlag)
info, err := app.Restore(cmd.Context(), host, dryRun)
noProject, _ := cmd.Flags().GetBool("no-project")
info, err := app.RestoreWithProject(cmd.Context(), host, noProject, dryRun)
if err != nil {
return err
}
Expand All@@ -935,7 +937,8 @@ func newUpdateCmd(repoFlag *string) *cobra.Command {
Short: "Pull repo changes and restore the effective machine profile",
RunE: func(cmd *cobra.Command, args []string) error {
app := svc(repoFlag)
info, err := app.Update(cmd.Context(), host)
noProject, _ := cmd.Flags().GetBool("no-project")
info, err := app.UpdateWithProject(cmd.Context(), host, noProject)
if err != nil {
return err
}
Expand DownExpand Up@@ -1141,6 +1144,24 @@ func printDoctor(w io.Writer, report service.DoctorReport) error {
}
}
}
if len(report.ProjectIssues) > 0 {
if _, err := fmt.Fprintln(w, "Project issues:"); err != nil {
return err
}
for _, issue := range report.ProjectIssues {
if _, err := fmt.Fprintf(w, " [%s] %s: %s", issue.Severity, issue.ProjectID, issue.Issue); err != nil {
return err
}
if issue.Suggestion != "" {
if _, err := fmt.Fprintf(w, " -> %s", issue.Suggestion); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
}
if len(report.PrunedProjects) > 0 {
if _, err := fmt.Fprintln(w, "Pruned empty project storage:"); err != nil {
return err
Expand Down
17 changes: 16 additions & 1 deletion service/doctor.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,14 +61,23 @@ type DoctorReport struct {
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
ProjectIssues []ProjectIssue
}

// ProjectIssue captures a project-scope health finding for doctor.
type ProjectIssue struct {
ProjectID string
Issue string
Severity string // "error" or "warning"
Suggestion string
}

// HasIssues reports whether the doctor found actionable issues.
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 {
if len(r.UnmarkedProjects) > 0 || len(r.EmptyProjects) > 0 || len(r.ProjectIssues) > 0 {
return true
}
for _, result := range r.ScopeResults {
Expand DownExpand Up@@ -182,6 +191,12 @@ func (s *Service) doctorScan(ctx context.Context, host string, all bool) (Doctor
report.UnmarkedProjects = unmarked
report.EmptyProjects = emptyProjects

projectIssues, err := s.scanProjectIssues(ctx)
if err != nil {
return DoctorReport{}, err
}
report.ProjectIssues = projectIssues

return report, nil
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add implicit project-scope detection and doctor health checks by polymorcodeus · Pull Request #25 · polymorcodeus/lnk · GitHub
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
19 changes: 15 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,10 +157,12 @@ lnk list --all # all scopes
```bash
lnk doctor # audit repo and profile health
lnk doctor --fix # apply safe automatic fixes
lnk doctor --fix --prune-empty # also remove empty host scopes
lnk doctor --fix --prune-empty # also remove empty host scopes and project storage
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.

### Format migration
Expand DownExpand Up@@ -207,6 +209,12 @@ lnk project untrack --keep .crush/** # remove a pattern but leave files man
lnk project remove # stop managing the project, restore all files
lnk project forget # stop managing the project, keep stored files

# implicit detection in update/restore
lnk update # also restores any detected project scope
lnk restore # also restores any detected project scope
lnk update --no-project # skip automatic project-scope detection
lnk restore --no-project # skip automatic project-scope detection

# 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
Expand All@@ -215,6 +223,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.

`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

- **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.
Expand DownExpand Up@@ -248,9 +258,9 @@ man man/lnk-project-push.1 # read a generated page
| `commit [-m message]` | Stage all changes and commit |
| `push` | Push existing commits |
| `pull` | Pull repo changes |
| `restore [--host H] [--dry-run]` | Restore symlinks without pulling |
| `update [--host H]` | Pull and restore the effective profile |
| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health |
| `restore [--host H] [--dry-run] [--no-project]` | Restore symlinks without pulling (auto-detects project scope) |
| `update [--host H] [--no-project]` | Pull and restore the effective profile (auto-detects project scope) |
| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health, including project scope |
| `format [--v1 \| --v2]` | Migrate repo format |
| `bootstrap` | Run bootstrap.sh explicitly |
| `project init` | Activate project scope in the current git repo |
Expand All@@ -271,6 +281,7 @@ Available with all commands:
| Option | Default | What it does |
| --- | --- | --- |
| `--repo <path>` | `~/.config/lnk` | Path to the lnk repository |
| `--no-project` | `false` | Skip automatic project-scope detection |

## Acknowledgements

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
v2.0.0
v2.1.0
25 changes: 23 additions & 2 deletions cmd/root.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ func NewRootCommand() *cobra.Command {
}

rootCmd.PersistentFlags().StringVar(&repoPath, "repo", "", "path to the lnk repository")
rootCmd.PersistentFlags().Bool("no-project", false, "skip automatic project scope detection")

rootCmd.AddCommand(newInitCmd(&repoPath))
rootCmd.AddCommand(newCloneCmd(&repoPath))
Expand DownExpand Up@@ -913,7 +914,8 @@ func newRestoreCmd(repoFlag *string) *cobra.Command {
Short: "Restore the effective machine profile",
RunE: func(cmd *cobra.Command, args []string) error {
app := svc(repoFlag)
info, err := app.Restore(cmd.Context(), host, dryRun)
noProject, _ := cmd.Flags().GetBool("no-project")
info, err := app.RestoreWithProject(cmd.Context(), host, noProject, dryRun)
if err != nil {
return err
}
Expand All@@ -935,7 +937,8 @@ func newUpdateCmd(repoFlag *string) *cobra.Command {
Short: "Pull repo changes and restore the effective machine profile",
RunE: func(cmd *cobra.Command, args []string) error {
app := svc(repoFlag)
info, err := app.Update(cmd.Context(), host)
noProject, _ := cmd.Flags().GetBool("no-project")
info, err := app.UpdateWithProject(cmd.Context(), host, noProject)
if err != nil {
return err
}
Expand DownExpand Up@@ -1141,6 +1144,24 @@ func printDoctor(w io.Writer, report service.DoctorReport) error {
}
}
}
if len(report.ProjectIssues) > 0 {
if _, err := fmt.Fprintln(w, "Project issues:"); err != nil {
return err
}
for _, issue := range report.ProjectIssues {
if _, err := fmt.Fprintf(w, " [%s] %s: %s", issue.Severity, issue.ProjectID, issue.Issue); err != nil {
return err
}
if issue.Suggestion != "" {
if _, err := fmt.Fprintf(w, " -> %s", issue.Suggestion); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
}
if len(report.PrunedProjects) > 0 {
if _, err := fmt.Fprintln(w, "Pruned empty project storage:"); err != nil {
return err
Expand Down
17 changes: 16 additions & 1 deletion service/doctor.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,14 +61,23 @@ type DoctorReport struct {
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
ProjectIssues []ProjectIssue
}

// ProjectIssue captures a project-scope health finding for doctor.
type ProjectIssue struct {
ProjectID string
Issue string
Severity string // "error" or "warning"
Suggestion string
}

// HasIssues reports whether the doctor found actionable issues.
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 {
if len(r.UnmarkedProjects) > 0 || len(r.EmptyProjects) > 0 || len(r.ProjectIssues) > 0 {
return true
}
for _, result := range r.ScopeResults {
Expand DownExpand Up@@ -182,6 +191,12 @@ func (s *Service) doctorScan(ctx context.Context, host string, all bool) (Doctor
report.UnmarkedProjects = unmarked
report.EmptyProjects = emptyProjects

projectIssues, err := s.scanProjectIssues(ctx)
if err != nil {
return DoctorReport{}, err
}
report.ProjectIssues = projectIssues

return report, nil
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add implicit project-scope detection and doctor health checks by polymorcodeus · Pull Request #25 · polymorcodeus/lnk · GitHub
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
19 changes: 15 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,10 +157,12 @@ lnk list --all # all scopes
```bash
lnk doctor # audit repo and profile health
lnk doctor --fix # apply safe automatic fixes
lnk doctor --fix --prune-empty # also remove empty host scopes
lnk doctor --fix --prune-empty # also remove empty host scopes and project storage
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.

### Format migration
Expand DownExpand Up@@ -207,6 +209,12 @@ lnk project untrack --keep .crush/** # remove a pattern but leave files man
lnk project remove # stop managing the project, restore all files
lnk project forget # stop managing the project, keep stored files

# implicit detection in update/restore
lnk update # also restores any detected project scope
lnk restore # also restores any detected project scope
lnk update --no-project # skip automatic project-scope detection
lnk restore --no-project # skip automatic project-scope detection

# 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
Expand All@@ -215,6 +223,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.

`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

- **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.
Expand DownExpand Up@@ -248,9 +258,9 @@ man man/lnk-project-push.1 # read a generated page
| `commit [-m message]` | Stage all changes and commit |
| `push` | Push existing commits |
| `pull` | Pull repo changes |
| `restore [--host H] [--dry-run]` | Restore symlinks without pulling |
| `update [--host H]` | Pull and restore the effective profile |
| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health |
| `restore [--host H] [--dry-run] [--no-project]` | Restore symlinks without pulling (auto-detects project scope) |
| `update [--host H] [--no-project]` | Pull and restore the effective profile (auto-detects project scope) |
| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health, including project scope |
| `format [--v1 \| --v2]` | Migrate repo format |
| `bootstrap` | Run bootstrap.sh explicitly |
| `project init` | Activate project scope in the current git repo |
Expand All@@ -271,6 +281,7 @@ Available with all commands:
| Option | Default | What it does |
| --- | --- | --- |
| `--repo <path>` | `~/.config/lnk` | Path to the lnk repository |
| `--no-project` | `false` | Skip automatic project-scope detection |

## Acknowledgements

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
v2.0.0
v2.1.0
25 changes: 23 additions & 2 deletions cmd/root.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ func NewRootCommand() *cobra.Command {
}

rootCmd.PersistentFlags().StringVar(&repoPath, "repo", "", "path to the lnk repository")
rootCmd.PersistentFlags().Bool("no-project", false, "skip automatic project scope detection")

rootCmd.AddCommand(newInitCmd(&repoPath))
rootCmd.AddCommand(newCloneCmd(&repoPath))
Expand DownExpand Up@@ -913,7 +914,8 @@ func newRestoreCmd(repoFlag *string) *cobra.Command {
Short: "Restore the effective machine profile",
RunE: func(cmd *cobra.Command, args []string) error {
app := svc(repoFlag)
info, err := app.Restore(cmd.Context(), host, dryRun)
noProject, _ := cmd.Flags().GetBool("no-project")
info, err := app.RestoreWithProject(cmd.Context(), host, noProject, dryRun)
if err != nil {
return err
}
Expand All@@ -935,7 +937,8 @@ func newUpdateCmd(repoFlag *string) *cobra.Command {
Short: "Pull repo changes and restore the effective machine profile",
RunE: func(cmd *cobra.Command, args []string) error {
app := svc(repoFlag)
info, err := app.Update(cmd.Context(), host)
noProject, _ := cmd.Flags().GetBool("no-project")
info, err := app.UpdateWithProject(cmd.Context(), host, noProject)
if err != nil {
return err
}
Expand DownExpand Up@@ -1141,6 +1144,24 @@ func printDoctor(w io.Writer, report service.DoctorReport) error {
}
}
}
if len(report.ProjectIssues) > 0 {
if _, err := fmt.Fprintln(w, "Project issues:"); err != nil {
return err
}
for _, issue := range report.ProjectIssues {
if _, err := fmt.Fprintf(w, " [%s] %s: %s", issue.Severity, issue.ProjectID, issue.Issue); err != nil {
return err
}
if issue.Suggestion != "" {
if _, err := fmt.Fprintf(w, " -> %s", issue.Suggestion); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
}
if len(report.PrunedProjects) > 0 {
if _, err := fmt.Fprintln(w, "Pruned empty project storage:"); err != nil {
return err
Expand Down
17 changes: 16 additions & 1 deletion service/doctor.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,14 +61,23 @@ type DoctorReport struct {
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
ProjectIssues []ProjectIssue
}

// ProjectIssue captures a project-scope health finding for doctor.
type ProjectIssue struct {
ProjectID string
Issue string
Severity string // "error" or "warning"
Suggestion string
}

// HasIssues reports whether the doctor found actionable issues.
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 {
if len(r.UnmarkedProjects) > 0 || len(r.EmptyProjects) > 0 || len(r.ProjectIssues) > 0 {
return true
}
for _, result := range r.ScopeResults {
Expand DownExpand Up@@ -182,6 +191,12 @@ func (s *Service) doctorScan(ctx context.Context, host string, all bool) (Doctor
report.UnmarkedProjects = unmarked
report.EmptyProjects = emptyProjects

projectIssues, err := s.scanProjectIssues(ctx)
if err != nil {
return DoctorReport{}, err
}
report.ProjectIssues = projectIssues

return report, nil
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Add implicit project-scope detection and doctor health checks by polymorcodeus · Pull Request #25 · polymorcodeus/lnk · GitHub
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
19 changes: 15 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,10 +157,12 @@ lnk list --all # all scopes
```bash
lnk doctor # audit repo and profile health
lnk doctor --fix # apply safe automatic fixes
lnk doctor --fix --prune-empty # also remove empty host scopes
lnk doctor --fix --prune-empty # also remove empty host scopes and project storage
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.

### Format migration
Expand DownExpand Up@@ -207,6 +209,12 @@ lnk project untrack --keep .crush/** # remove a pattern but leave files man
lnk project remove # stop managing the project, restore all files
lnk project forget # stop managing the project, keep stored files

# implicit detection in update/restore
lnk update # also restores any detected project scope
lnk restore # also restores any detected project scope
lnk update --no-project # skip automatic project-scope detection
lnk restore --no-project # skip automatic project-scope detection

# 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
Expand All@@ -215,6 +223,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.

`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

- **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.
Expand DownExpand Up@@ -248,9 +258,9 @@ man man/lnk-project-push.1 # read a generated page
| `commit [-m message]` | Stage all changes and commit |
| `push` | Push existing commits |
| `pull` | Pull repo changes |
| `restore [--host H] [--dry-run]` | Restore symlinks without pulling |
| `update [--host H]` | Pull and restore the effective profile |
| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health |
| `restore [--host H] [--dry-run] [--no-project]` | Restore symlinks without pulling (auto-detects project scope) |
| `update [--host H] [--no-project]` | Pull and restore the effective profile (auto-detects project scope) |
| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health, including project scope |
| `format [--v1 \| --v2]` | Migrate repo format |
| `bootstrap` | Run bootstrap.sh explicitly |
| `project init` | Activate project scope in the current git repo |
Expand All@@ -271,6 +281,7 @@ Available with all commands:
| Option | Default | What it does |
| --- | --- | --- |
| `--repo <path>` | `~/.config/lnk` | Path to the lnk repository |
| `--no-project` | `false` | Skip automatic project-scope detection |

## Acknowledgements

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
v2.0.0
v2.1.0
25 changes: 23 additions & 2 deletions cmd/root.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ func NewRootCommand() *cobra.Command {
}

rootCmd.PersistentFlags().StringVar(&repoPath, "repo", "", "path to the lnk repository")
rootCmd.PersistentFlags().Bool("no-project", false, "skip automatic project scope detection")

rootCmd.AddCommand(newInitCmd(&repoPath))
rootCmd.AddCommand(newCloneCmd(&repoPath))
Expand DownExpand Up@@ -913,7 +914,8 @@ func newRestoreCmd(repoFlag *string) *cobra.Command {
Short: "Restore the effective machine profile",
RunE: func(cmd *cobra.Command, args []string) error {
app := svc(repoFlag)
info, err := app.Restore(cmd.Context(), host, dryRun)
noProject, _ := cmd.Flags().GetBool("no-project")
info, err := app.RestoreWithProject(cmd.Context(), host, noProject, dryRun)
if err != nil {
return err
}
Expand All@@ -935,7 +937,8 @@ func newUpdateCmd(repoFlag *string) *cobra.Command {
Short: "Pull repo changes and restore the effective machine profile",
RunE: func(cmd *cobra.Command, args []string) error {
app := svc(repoFlag)
info, err := app.Update(cmd.Context(), host)
noProject, _ := cmd.Flags().GetBool("no-project")
info, err := app.UpdateWithProject(cmd.Context(), host, noProject)
if err != nil {
return err
}
Expand DownExpand Up@@ -1141,6 +1144,24 @@ func printDoctor(w io.Writer, report service.DoctorReport) error {
}
}
}
if len(report.ProjectIssues) > 0 {
if _, err := fmt.Fprintln(w, "Project issues:"); err != nil {
return err
}
for _, issue := range report.ProjectIssues {
if _, err := fmt.Fprintf(w, " [%s] %s: %s", issue.Severity, issue.ProjectID, issue.Issue); err != nil {
return err
}
if issue.Suggestion != "" {
if _, err := fmt.Fprintf(w, " -> %s", issue.Suggestion); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
}
if len(report.PrunedProjects) > 0 {
if _, err := fmt.Fprintln(w, "Pruned empty project storage:"); err != nil {
return err
Expand Down
17 changes: 16 additions & 1 deletion service/doctor.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,14 +61,23 @@ type DoctorReport struct {
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
ProjectIssues []ProjectIssue
}

// ProjectIssue captures a project-scope health finding for doctor.
type ProjectIssue struct {
ProjectID string
Issue string
Severity string // "error" or "warning"
Suggestion string
}

// HasIssues reports whether the doctor found actionable issues.
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 {
if len(r.UnmarkedProjects) > 0 || len(r.EmptyProjects) > 0 || len(r.ProjectIssues) > 0 {
return true
}
for _, result := range r.ScopeResults {
Expand DownExpand Up@@ -182,6 +191,12 @@ func (s *Service) doctorScan(ctx context.Context, host string, all bool) (Doctor
report.UnmarkedProjects = unmarked
report.EmptyProjects = emptyProjects

projectIssues, err := s.scanProjectIssues(ctx)
if err != nil {
return DoctorReport{}, err
}
report.ProjectIssues = projectIssues

return report, nil
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add implicit project-scope detection and doctor health checks by polymorcodeus · Pull Request #25 · polymorcodeus/lnk · GitHub
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
19 changes: 15 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,10 +157,12 @@ lnk list --all # all scopes
```bash
lnk doctor # audit repo and profile health
lnk doctor --fix # apply safe automatic fixes
lnk doctor --fix --prune-empty # also remove empty host scopes
lnk doctor --fix --prune-empty # also remove empty host scopes and project storage
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.

### Format migration
Expand DownExpand Up@@ -207,6 +209,12 @@ lnk project untrack --keep .crush/** # remove a pattern but leave files man
lnk project remove # stop managing the project, restore all files
lnk project forget # stop managing the project, keep stored files

# implicit detection in update/restore
lnk update # also restores any detected project scope
lnk restore # also restores any detected project scope
lnk update --no-project # skip automatic project-scope detection
lnk restore --no-project # skip automatic project-scope detection

# 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
Expand All@@ -215,6 +223,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.

`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

- **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.
Expand DownExpand Up@@ -248,9 +258,9 @@ man man/lnk-project-push.1 # read a generated page
| `commit [-m message]` | Stage all changes and commit |
| `push` | Push existing commits |
| `pull` | Pull repo changes |
| `restore [--host H] [--dry-run]` | Restore symlinks without pulling |
| `update [--host H]` | Pull and restore the effective profile |
| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health |
| `restore [--host H] [--dry-run] [--no-project]` | Restore symlinks without pulling (auto-detects project scope) |
| `update [--host H] [--no-project]` | Pull and restore the effective profile (auto-detects project scope) |
| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health, including project scope |
| `format [--v1 \| --v2]` | Migrate repo format |
| `bootstrap` | Run bootstrap.sh explicitly |
| `project init` | Activate project scope in the current git repo |
Expand All@@ -271,6 +281,7 @@ Available with all commands:
| Option | Default | What it does |
| --- | --- | --- |
| `--repo <path>` | `~/.config/lnk` | Path to the lnk repository |
| `--no-project` | `false` | Skip automatic project-scope detection |

## Acknowledgements

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
v2.0.0
v2.1.0
25 changes: 23 additions & 2 deletions cmd/root.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ func NewRootCommand() *cobra.Command {
}

rootCmd.PersistentFlags().StringVar(&repoPath, "repo", "", "path to the lnk repository")
rootCmd.PersistentFlags().Bool("no-project", false, "skip automatic project scope detection")

rootCmd.AddCommand(newInitCmd(&repoPath))
rootCmd.AddCommand(newCloneCmd(&repoPath))
Expand DownExpand Up@@ -913,7 +914,8 @@ func newRestoreCmd(repoFlag *string) *cobra.Command {
Short: "Restore the effective machine profile",
RunE: func(cmd *cobra.Command, args []string) error {
app := svc(repoFlag)
info, err := app.Restore(cmd.Context(), host, dryRun)
noProject, _ := cmd.Flags().GetBool("no-project")
info, err := app.RestoreWithProject(cmd.Context(), host, noProject, dryRun)
if err != nil {
return err
}
Expand All@@ -935,7 +937,8 @@ func newUpdateCmd(repoFlag *string) *cobra.Command {
Short: "Pull repo changes and restore the effective machine profile",
RunE: func(cmd *cobra.Command, args []string) error {
app := svc(repoFlag)
info, err := app.Update(cmd.Context(), host)
noProject, _ := cmd.Flags().GetBool("no-project")
info, err := app.UpdateWithProject(cmd.Context(), host, noProject)
if err != nil {
return err
}
Expand DownExpand Up@@ -1141,6 +1144,24 @@ func printDoctor(w io.Writer, report service.DoctorReport) error {
}
}
}
if len(report.ProjectIssues) > 0 {
if _, err := fmt.Fprintln(w, "Project issues:"); err != nil {
return err
}
for _, issue := range report.ProjectIssues {
if _, err := fmt.Fprintf(w, " [%s] %s: %s", issue.Severity, issue.ProjectID, issue.Issue); err != nil {
return err
}
if issue.Suggestion != "" {
if _, err := fmt.Fprintf(w, " -> %s", issue.Suggestion); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
}
if len(report.PrunedProjects) > 0 {
if _, err := fmt.Fprintln(w, "Pruned empty project storage:"); err != nil {
return err
Expand Down
17 changes: 16 additions & 1 deletion service/doctor.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,14 +61,23 @@ type DoctorReport struct {
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
ProjectIssues []ProjectIssue
}

// ProjectIssue captures a project-scope health finding for doctor.
type ProjectIssue struct {
ProjectID string
Issue string
Severity string // "error" or "warning"
Suggestion string
}

// HasIssues reports whether the doctor found actionable issues.
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 {
if len(r.UnmarkedProjects) > 0 || len(r.EmptyProjects) > 0 || len(r.ProjectIssues) > 0 {
return true
}
for _, result := range r.ScopeResults {
Expand DownExpand Up@@ -182,6 +191,12 @@ func (s *Service) doctorScan(ctx context.Context, host string, all bool) (Doctor
report.UnmarkedProjects = unmarked
report.EmptyProjects = emptyProjects

projectIssues, err := s.scanProjectIssues(ctx)
if err != nil {
return DoctorReport{}, err
}
report.ProjectIssues = projectIssues

return report, nil
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add implicit project-scope detection and doctor health checks by polymorcodeus · Pull Request #25 · polymorcodeus/lnk · GitHub
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
19 changes: 15 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,10 +157,12 @@ lnk list --all # all scopes
```bash
lnk doctor # audit repo and profile health
lnk doctor --fix # apply safe automatic fixes
lnk doctor --fix --prune-empty # also remove empty host scopes
lnk doctor --fix --prune-empty # also remove empty host scopes and project storage
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.

### Format migration
Expand DownExpand Up@@ -207,6 +209,12 @@ lnk project untrack --keep .crush/** # remove a pattern but leave files man
lnk project remove # stop managing the project, restore all files
lnk project forget # stop managing the project, keep stored files

# implicit detection in update/restore
lnk update # also restores any detected project scope
lnk restore # also restores any detected project scope
lnk update --no-project # skip automatic project-scope detection
lnk restore --no-project # skip automatic project-scope detection

# 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
Expand All@@ -215,6 +223,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.

`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

- **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.
Expand DownExpand Up@@ -248,9 +258,9 @@ man man/lnk-project-push.1 # read a generated page
| `commit [-m message]` | Stage all changes and commit |
| `push` | Push existing commits |
| `pull` | Pull repo changes |
| `restore [--host H] [--dry-run]` | Restore symlinks without pulling |
| `update [--host H]` | Pull and restore the effective profile |
| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health |
| `restore [--host H] [--dry-run] [--no-project]` | Restore symlinks without pulling (auto-detects project scope) |
| `update [--host H] [--no-project]` | Pull and restore the effective profile (auto-detects project scope) |
| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health, including project scope |
| `format [--v1 \| --v2]` | Migrate repo format |
| `bootstrap` | Run bootstrap.sh explicitly |
| `project init` | Activate project scope in the current git repo |
Expand All@@ -271,6 +281,7 @@ Available with all commands:
| Option | Default | What it does |
| --- | --- | --- |
| `--repo <path>` | `~/.config/lnk` | Path to the lnk repository |
| `--no-project` | `false` | Skip automatic project-scope detection |

## Acknowledgements

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
v2.0.0
v2.1.0
25 changes: 23 additions & 2 deletions cmd/root.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ func NewRootCommand() *cobra.Command {
}

rootCmd.PersistentFlags().StringVar(&repoPath, "repo", "", "path to the lnk repository")
rootCmd.PersistentFlags().Bool("no-project", false, "skip automatic project scope detection")

rootCmd.AddCommand(newInitCmd(&repoPath))
rootCmd.AddCommand(newCloneCmd(&repoPath))
Expand DownExpand Up@@ -913,7 +914,8 @@ func newRestoreCmd(repoFlag *string) *cobra.Command {
Short: "Restore the effective machine profile",
RunE: func(cmd *cobra.Command, args []string) error {
app := svc(repoFlag)
info, err := app.Restore(cmd.Context(), host, dryRun)
noProject, _ := cmd.Flags().GetBool("no-project")
info, err := app.RestoreWithProject(cmd.Context(), host, noProject, dryRun)
if err != nil {
return err
}
Expand All@@ -935,7 +937,8 @@ func newUpdateCmd(repoFlag *string) *cobra.Command {
Short: "Pull repo changes and restore the effective machine profile",
RunE: func(cmd *cobra.Command, args []string) error {
app := svc(repoFlag)
info, err := app.Update(cmd.Context(), host)
noProject, _ := cmd.Flags().GetBool("no-project")
info, err := app.UpdateWithProject(cmd.Context(), host, noProject)
if err != nil {
return err
}
Expand DownExpand Up@@ -1141,6 +1144,24 @@ func printDoctor(w io.Writer, report service.DoctorReport) error {
}
}
}
if len(report.ProjectIssues) > 0 {
if _, err := fmt.Fprintln(w, "Project issues:"); err != nil {
return err
}
for _, issue := range report.ProjectIssues {
if _, err := fmt.Fprintf(w, " [%s] %s: %s", issue.Severity, issue.ProjectID, issue.Issue); err != nil {
return err
}
if issue.Suggestion != "" {
if _, err := fmt.Fprintf(w, " -> %s", issue.Suggestion); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
}
if len(report.PrunedProjects) > 0 {
if _, err := fmt.Fprintln(w, "Pruned empty project storage:"); err != nil {
return err
Expand Down
17 changes: 16 additions & 1 deletion service/doctor.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,14 +61,23 @@ type DoctorReport struct {
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
ProjectIssues []ProjectIssue
}

// ProjectIssue captures a project-scope health finding for doctor.
type ProjectIssue struct {
ProjectID string
Issue string
Severity string // "error" or "warning"
Suggestion string
}

// HasIssues reports whether the doctor found actionable issues.
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 {
if len(r.UnmarkedProjects) > 0 || len(r.EmptyProjects) > 0 || len(r.ProjectIssues) > 0 {
return true
}
for _, result := range r.ScopeResults {
Expand DownExpand Up@@ -182,6 +191,12 @@ func (s *Service) doctorScan(ctx context.Context, host string, all bool) (Doctor
report.UnmarkedProjects = unmarked
report.EmptyProjects = emptyProjects

projectIssues, err := s.scanProjectIssues(ctx)
if err != nil {
return DoctorReport{}, err
}
report.ProjectIssues = projectIssues

return report, nil
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Add implicit project-scope detection and doctor health checks by polymorcodeus · Pull Request #25 · polymorcodeus/lnk · GitHub
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
19 changes: 15 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,10 +157,12 @@ lnk list --all # all scopes
```bash
lnk doctor # audit repo and profile health
lnk doctor --fix # apply safe automatic fixes
lnk doctor --fix --prune-empty # also remove empty host scopes
lnk doctor --fix --prune-empty # also remove empty host scopes and project storage
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.

### Format migration
Expand DownExpand Up@@ -207,6 +209,12 @@ lnk project untrack --keep .crush/** # remove a pattern but leave files man
lnk project remove # stop managing the project, restore all files
lnk project forget # stop managing the project, keep stored files

# implicit detection in update/restore
lnk update # also restores any detected project scope
lnk restore # also restores any detected project scope
lnk update --no-project # skip automatic project-scope detection
lnk restore --no-project # skip automatic project-scope detection

# 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
Expand All@@ -215,6 +223,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.

`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

- **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.
Expand DownExpand Up@@ -248,9 +258,9 @@ man man/lnk-project-push.1 # read a generated page
| `commit [-m message]` | Stage all changes and commit |
| `push` | Push existing commits |
| `pull` | Pull repo changes |
| `restore [--host H] [--dry-run]` | Restore symlinks without pulling |
| `update [--host H]` | Pull and restore the effective profile |
| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health |
| `restore [--host H] [--dry-run] [--no-project]` | Restore symlinks without pulling (auto-detects project scope) |
| `update [--host H] [--no-project]` | Pull and restore the effective profile (auto-detects project scope) |
| `doctor [--host H \| --all] [--fix] [--prune-empty]` | Audit and fix repo health, including project scope |
| `format [--v1 \| --v2]` | Migrate repo format |
| `bootstrap` | Run bootstrap.sh explicitly |
| `project init` | Activate project scope in the current git repo |
Expand All@@ -271,6 +281,7 @@ Available with all commands:
| Option | Default | What it does |
| --- | --- | --- |
| `--repo <path>` | `~/.config/lnk` | Path to the lnk repository |
| `--no-project` | `false` | Skip automatic project-scope detection |

## Acknowledgements

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
v2.0.0
v2.1.0
25 changes: 23 additions & 2 deletions cmd/root.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ func NewRootCommand() *cobra.Command {
}

rootCmd.PersistentFlags().StringVar(&repoPath, "repo", "", "path to the lnk repository")
rootCmd.PersistentFlags().Bool("no-project", false, "skip automatic project scope detection")

rootCmd.AddCommand(newInitCmd(&repoPath))
rootCmd.AddCommand(newCloneCmd(&repoPath))
Expand DownExpand Up@@ -913,7 +914,8 @@ func newRestoreCmd(repoFlag *string) *cobra.Command {
Short: "Restore the effective machine profile",
RunE: func(cmd *cobra.Command, args []string) error {
app := svc(repoFlag)
info, err := app.Restore(cmd.Context(), host, dryRun)
noProject, _ := cmd.Flags().GetBool("no-project")
info, err := app.RestoreWithProject(cmd.Context(), host, noProject, dryRun)
if err != nil {
return err
}
Expand All@@ -935,7 +937,8 @@ func newUpdateCmd(repoFlag *string) *cobra.Command {
Short: "Pull repo changes and restore the effective machine profile",
RunE: func(cmd *cobra.Command, args []string) error {
app := svc(repoFlag)
info, err := app.Update(cmd.Context(), host)
noProject, _ := cmd.Flags().GetBool("no-project")
info, err := app.UpdateWithProject(cmd.Context(), host, noProject)
if err != nil {
return err
}
Expand DownExpand Up@@ -1141,6 +1144,24 @@ func printDoctor(w io.Writer, report service.DoctorReport) error {
}
}
}
if len(report.ProjectIssues) > 0 {
if _, err := fmt.Fprintln(w, "Project issues:"); err != nil {
return err
}
for _, issue := range report.ProjectIssues {
if _, err := fmt.Fprintf(w, " [%s] %s: %s", issue.Severity, issue.ProjectID, issue.Issue); err != nil {
return err
}
if issue.Suggestion != "" {
if _, err := fmt.Fprintf(w, " -> %s", issue.Suggestion); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
}
if len(report.PrunedProjects) > 0 {
if _, err := fmt.Fprintln(w, "Pruned empty project storage:"); err != nil {
return err
Expand Down
17 changes: 16 additions & 1 deletion service/doctor.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,14 +61,23 @@ type DoctorReport struct {
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
ProjectIssues []ProjectIssue
}

// ProjectIssue captures a project-scope health finding for doctor.
type ProjectIssue struct {
ProjectID string
Issue string
Severity string // "error" or "warning"
Suggestion string
}

// HasIssues reports whether the doctor found actionable issues.
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 {
if len(r.UnmarkedProjects) > 0 || len(r.EmptyProjects) > 0 || len(r.ProjectIssues) > 0 {
return true
}
for _, result := range r.ScopeResults {
Expand DownExpand Up@@ -182,6 +191,12 @@ func (s *Service) doctorScan(ctx context.Context, host string, all bool) (Doctor
report.UnmarkedProjects = unmarked
report.EmptyProjects = emptyProjects

projectIssues, err := s.scanProjectIssues(ctx)
if err != nil {
return DoctorReport{}, err
}
report.ProjectIssues = projectIssues

return report, nil
}

Expand Down
Loading
Loading