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
2 changes: 2 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,6 +152,8 @@ lnk list --host work # host-specific
lnk list --all # all scopes
```

When listing all scopes, host profiles show `[active]` if at least one managed symlink exists on the current machine, or `[not installed]` otherwise. Common scope is always active.

### Health checks

```bash
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
v2.3.0
v2.3.1
10 changes: 9 additions & 1 deletion cmd/root.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -871,7 +871,15 @@ func newListCmd(repoFlag *string) *cobra.Command {
return err
}
}
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s:\n", scope.Name); err != nil {
header := scope.Name
if scope.Name != "common" {
if scope.Active {
header += " [active]"
} else {
header += " [not installed]"
}
}
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s:\n", header); err != nil {
return err
}
if len(scope.Items) == 0 {
Expand Down
45 changes: 43 additions & 2 deletions service/list.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,9 @@ package service

import (
"context"
"os"
"path/filepath"
"strings"

"github.com/polymorcodeus/lnk/internal/lnkerror"
"github.com/polymorcodeus/lnk/internal/tracker"
Expand DownExpand Up@@ -39,10 +42,48 @@ func (s *Service) List(ctx context.Context, host string, all bool) (ListResult,
if err != nil {
return ListResult{}, err
}
active := scope == tracker.CommonScope
if !active && len(items) > 0 {
active = s.isHostActive(scope, items)
}
result.Scopes = append(result.Scopes, ScopeList{
Name: scope,
Items: items,
Name: scope,
Items: items,
Active: active,
})
}
return result, nil
}

// isHostActive reports whether at least one symlink for the given host scope
// exists on the current machine and points into the repo's host storage.
func (s *Service) isHostActive(host string, items []string) bool {
format, err := s.getFormat()
if err != nil {
return false
}
tr := tracker.New(s.repoPath, host, format)
storagePath, err := tr.HostStoragePath()
if err != nil {
return false
}
for _, item := range items {
livePath, err := s.resolver.ToLive(item)
if err != nil {
continue
}
target, err := os.Readlink(livePath)
if err != nil {
continue
}
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(livePath), target)
}
target = filepath.Clean(target)
storagePath = filepath.Clean(storagePath)
if strings.HasPrefix(target, storagePath+string(filepath.Separator)) || target == storagePath {
return true
}
}
return false
}
183 changes: 183 additions & 0 deletions service/list_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,10 @@ package service_test

import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/polymorcodeus/lnk/internal/testhelpers"
Expand DownExpand Up@@ -238,6 +241,186 @@ func TestList_UninitializedRepo(t *testing.T) {
}
}

func TestList_ActiveHost(t *testing.T) {
svc, home := testhelpers.TestHome(t)
repoPath := svc.RepoPath()

setupTrackedFile(t, repoPath, home, "personal", ".bashrc", "# bashrc")

result, err := svc.List(context.Background(), "", true)
if err != nil {
t.Fatalf("List --all: %v", err)
}

personal := findScope(result, "personal")
if personal == nil {
t.Fatal("expected personal scope")
}
if !personal.Active {
t.Error("personal scope should be active when symlink exists")
}
}

func TestList_InactiveHost(t *testing.T) {
svc, _ := testhelpers.TestHome(t)
repoPath := svc.RepoPath()

// Track a file without creating a symlink at the live path.
setupTrackedFileNoSymlink(t, repoPath, "work", ".ssh/config", "host work")

result, err := svc.List(context.Background(), "", true)
if err != nil {
t.Fatalf("List --all: %v", err)
}

work := findScope(result, "work")
if work == nil {
t.Fatal("expected work scope")
}
if work.Active {
t.Error("work scope should be inactive when no symlink exists")
}
}

func TestList_CommonAlwaysActive(t *testing.T) {
svc, _ := testhelpers.TestHome(t)
repoPath := svc.RepoPath()

// Track only common files, no symlinks.
setupTrackedFileNoSymlink(t, repoPath, "common", ".bashrc", "# bashrc")

result, err := svc.List(context.Background(), "", false)
if err != nil {
t.Fatalf("List: %v", err)
}

if len(result.Scopes) != 1 {
t.Fatalf("expected 1 scope, got %d", len(result.Scopes))
}
if !result.Scopes[0].Active {
t.Error("common scope should always be active")
}
}

func TestList_HostScopeNotActiveWhenFlag(t *testing.T) {
svc, home := testhelpers.TestHome(t)
repoPath := svc.RepoPath()

setupTrackedFile(t, repoPath, home, "personal", ".bashrc", "# bashrc")

result, err := svc.List(context.Background(), "personal", false)
if err != nil {
t.Fatalf("List --host personal: %v", err)
}

if len(result.Scopes) != 1 {
t.Fatalf("expected 1 scope, got %d", len(result.Scopes))
}
// When listing a specific host, Active should still reflect reality.
if !result.Scopes[0].Active {
t.Error("active personal host scope should be Active=true")
}
}

func TestList_EmptyHostScopeNotActive(t *testing.T) {
svc, _ := testhelpers.TestHome(t)
repoPath := svc.RepoPath()

// Create .lnk.ghost file but leave it empty.
if err := os.WriteFile(filepath.Join(repoPath, ".lnk.ghost"), []byte{}, 0o644); err != nil {
t.Fatal(err)
}

result, err := svc.List(context.Background(), "", true)
if err != nil {
t.Fatalf("List --all: %v", err)
}

ghost := findScope(result, "ghost")
if ghost == nil {
t.Fatal("expected ghost scope")
}
if ghost.Active {
t.Error("empty ghost scope should not be active")
}
}

// setupTrackedFileNoSymlink creates repo storage and tracker entries without
// creating a symlink at the live path. Used to simulate a host that is
// tracked but not installed on the current machine.
func setupTrackedFileNoSymlink(t *testing.T, repoPath, scope, relativePath, content string) {
t.Helper()

var storageRoot string
if scope == "" || scope == "common" {
marker, _ := os.ReadFile(filepath.Join(repoPath, ".lnkrepo"))
if strings.Contains(string(marker), "version=1") {
storageRoot = repoPath
} else if len(marker) == 0 {
if _, err := os.Stat(filepath.Join(repoPath, ".lnk")); err == nil {
storageRoot = repoPath
} else {
storageRoot = filepath.Join(repoPath, "common.lnk")
}
} else {
storageRoot = filepath.Join(repoPath, "common.lnk")
}
} else {
storageRoot = filepath.Join(repoPath, scope+".lnk")
}

storagePath := filepath.Join(storageRoot, relativePath)
if err := os.MkdirAll(filepath.Dir(storagePath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(storagePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}

var trackerName string
if scope == "" || scope == "common" {
if storageRoot == repoPath {
trackerName = ".lnk"
} else {
trackerName = ".lnk.common"
}
} else {
trackerName = ".lnk." + scope
}

trackerPath := filepath.Join(repoPath, trackerName)
existing, _ := os.ReadFile(trackerPath)
entries := strings.TrimSpace(string(existing))
if entries != "" {
entries += "\n"
}
entries += relativePath + "\n"
if err := os.WriteFile(trackerPath, []byte(entries), 0o644); err != nil {
t.Fatal(err)
}

commitCmds := [][]string{
{"git", "-C", repoPath, "add", "."},
{"git", "-C", repoPath, "commit", "-m", "lnk: added " + relativePath},
}
for _, args := range commitCmds {
cmd := exec.Command(args[0], args[1:]...)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git setup failed: %v\n%s", err, out)
}
}
}

// findScope returns the ScopeList with the given name, or nil.
func findScope(result service.ListResult, name string) *service.ScopeList {
for i := range result.Scopes {
if result.Scopes[i].Name == name {
return &result.Scopes[i]
}
}
return nil
}

// scopeNames extracts scope names from a ListResult for use in failure messages.
func scopeNames(result service.ListResult) []string {
names := make([]string, len(result.Scopes))
Expand Down
5 changes: 3 additions & 2 deletions service/service.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,8 +63,9 @@ func WithGitOptions(opts ...gitpkg.Option) Option {

// ScopeList describes tracked items for one storage scope.
type ScopeList struct {
Name string
Items []string
Name string
Items []string
Active bool
}

// ListResult contains tracked items grouped by storage scope.
Expand Down
Loading