From 0bfa838d8ab9bdb57b95a943e980105438000d23 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sat, 29 Aug 2026 00:59:17 -0500 Subject: [PATCH 1/2] feat: add active header for host profiles with at least one active lnk --- cmd/root.go | 10 ++- service/list.go | 45 ++++++++++- service/list_test.go | 183 +++++++++++++++++++++++++++++++++++++++++++ service/service.go | 5 +- 4 files changed, 238 insertions(+), 5 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index a28520b..2df99d3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 { diff --git a/service/list.go b/service/list.go index 45d7eaf..1b05a2f 100644 --- a/service/list.go +++ b/service/list.go @@ -2,6 +2,9 @@ package service import ( "context" + "os" + "path/filepath" + "strings" "github.com/polymorcodeus/lnk/internal/lnkerror" "github.com/polymorcodeus/lnk/internal/tracker" @@ -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 +} diff --git a/service/list_test.go b/service/list_test.go index a603956..44c0eb2 100644 --- a/service/list_test.go +++ b/service/list_test.go @@ -2,7 +2,10 @@ package service_test import ( "context" + "os" + "os/exec" "path/filepath" + "strings" "testing" "github.com/polymorcodeus/lnk/internal/testhelpers" @@ -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)) diff --git a/service/service.go b/service/service.go index 227f16f..9ba9817 100644 --- a/service/service.go +++ b/service/service.go @@ -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. From 3d3d37faf4ddef7e67fde3e38c57323c3b89f1f6 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sat, 29 Aug 2026 01:05:04 -0500 Subject: [PATCH 2/2] docs: updated README + version bump --- README.md | 2 ++ VERSION | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 671e9ed..169fb96 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/VERSION b/VERSION index b1d18bc..aaf7425 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v2.3.0 +v2.3.1