From ca9f12c8e2c60cdbe56325f8a4859f1aaa918f56 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sat, 29 Aug 2026 20:19:39 -0500 Subject: [PATCH 1/3] feat: add create for empty file/dir --- cmd/create.go | 77 +++++++++ cmd/create_test.go | 140 ++++++++++++++++ cmd/root.go | 1 + cmd/root_test.go | 2 +- internal/lnkerror/error.go | 2 + service/create.go | 221 ++++++++++++++++++++++++++ service/create_test.go | 317 +++++++++++++++++++++++++++++++++++++ 7 files changed, 759 insertions(+), 1 deletion(-) create mode 100644 cmd/create.go create mode 100644 cmd/create_test.go create mode 100644 service/create.go create mode 100644 service/create_test.go diff --git a/cmd/create.go b/cmd/create.go new file mode 100644 index 0000000..8129145 --- /dev/null +++ b/cmd/create.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/polymorcodeus/lnk/internal/lnkerror" + "github.com/polymorcodeus/lnk/service" +) + +// newCreateCmd returns the "create" subcommand. +func newCreateCmd(repoFlag *string) *cobra.Command { + var host string + var asDir bool + + cmd := &cobra.Command{ + Use: "create [--dir] [--host H] ", + Short: "Create and track empty files or directories", + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { + return err + } + if asDir { + return nil + } + if err := validateCreateArgs(args); err != nil { + return err + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + app := svc(repoFlag) + + dirMode := asDir || isDirArg(args[0]) + if err := app.Create(cmd.Context(), host, args, service.CreateOptions{AsDir: dirMode}); err != nil { + return err + } + + kind := "file(s)" + if dirMode { + kind = "dir(s)" + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Created and tracked %d %s in %s scope\n", len(args), kind, service.NormalizeHost(host)) + return err + }, + } + + cmd.Flags().StringVar(&host, "host", "", "create paths in a host-specific scope") + cmd.Flags().BoolVar(&asDir, "dir", false, "create directories instead of files") + return cmd +} + +// validateCreateArgs ensures all positional arguments agree on file vs directory +// semantics when --dir is not set. +func validateCreateArgs(args []string) error { + sawFile := false + sawDir := false + for _, arg := range args { + if isDirArg(arg) { + sawDir = true + } else { + sawFile = true + } + if sawFile && sawDir { + return lnkerror.WithSuggestion(lnkerror.ErrMixedCreateTypes, "use --dir when creating directories, or run separate commands for files and directories") + } + } + return nil +} + +// isDirArg reports whether arg uses a trailing path separator to indicate a +// directory, allowing both Unix and Windows separators. +func isDirArg(arg string) bool { + return arg != "" && (strings.HasSuffix(arg, "/") || strings.HasSuffix(arg, "\\")) +} diff --git a/cmd/create_test.go b/cmd/create_test.go new file mode 100644 index 0000000..faace20 --- /dev/null +++ b/cmd/create_test.go @@ -0,0 +1,140 @@ +package cmd_test + +import ( + "bytes" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/polymorcodeus/lnk/cmd" + "github.com/polymorcodeus/lnk/internal/lnkerror" + "github.com/polymorcodeus/lnk/internal/testhelpers" +) + +func TestCreateCmd_MixedFileAndDir(t *testing.T) { + svc, home := testhelpers.TestHome(t) + _ = svc + + root := cmd.NewRootCommand() + root.SetArgs([]string{ + "create", + filepath.Join(home, ".config", "awesome") + string(filepath.Separator), + filepath.Join(home, ".bashrc"), + }) + + err := root.Execute() + if err == nil { + t.Fatal("expected error for mixed file/directory create, got nil") + } + if !errors.Is(err, lnkerror.ErrMixedCreateTypes) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrMixedCreateTypes) + } +} + +func TestCreateCmd_DirFlagCreatesDirectory(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoPath := svc.RepoPath() + + root := cmd.NewRootCommand() + root.SetArgs([]string{ + "create", + "--dir", + filepath.Join(home, ".config", "awesome"), + }) + + var buf bytes.Buffer + root.SetOut(&buf) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Created and tracked 1 dir(s)") { + t.Errorf("unexpected output: %q", out) + } + + storagePath := filepath.Join(repoPath, "common.lnk", ".config", "awesome") + livePath := filepath.Join(home, ".config", "awesome") + testhelpers.AssertSymlink(t, livePath, storagePath) + testhelpers.AssertTracked(t, repoPath, ".config/awesome") +} + +func TestCreateCmd_TrailingSlashCreatesDirectory(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoPath := svc.RepoPath() + + root := cmd.NewRootCommand() + root.SetArgs([]string{ + "create", + filepath.Join(home, ".config", "awesome") + string(filepath.Separator), + }) + + var buf bytes.Buffer + root.SetOut(&buf) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Created and tracked 1 dir(s)") { + t.Errorf("unexpected output: %q", out) + } + + storagePath := filepath.Join(repoPath, "common.lnk", ".config", "awesome") + livePath := filepath.Join(home, ".config", "awesome") + testhelpers.AssertSymlink(t, livePath, storagePath) + testhelpers.AssertTracked(t, repoPath, ".config/awesome") +} + +func TestCreateCmd_MultipleDirsWithTrailingSlash(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoPath := svc.RepoPath() + + root := cmd.NewRootCommand() + root.SetArgs([]string{ + "create", + filepath.Join(home, ".config", "awesome") + string(filepath.Separator), + filepath.Join(home, ".config", "other") + string(filepath.Separator), + }) + + var buf bytes.Buffer + root.SetOut(&buf) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Created and tracked 2 dir(s)") { + t.Errorf("unexpected output: %q", out) + } + + testhelpers.AssertTracked(t, repoPath, ".config/awesome") + testhelpers.AssertTracked(t, repoPath, ".config/other") +} + +func TestCreateCmd_FileOutput(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoPath := svc.RepoPath() + + root := cmd.NewRootCommand() + root.SetArgs([]string{ + "create", + filepath.Join(home, ".bashrc"), + }) + + var buf bytes.Buffer + root.SetOut(&buf) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Created and tracked 1 file(s)") { + t.Errorf("unexpected output: %q", out) + } + + storagePath := filepath.Join(repoPath, "common.lnk", ".bashrc") + testhelpers.AssertSymlink(t, filepath.Join(home, ".bashrc"), storagePath) + testhelpers.AssertTracked(t, repoPath, ".bashrc") +} diff --git a/cmd/root.go b/cmd/root.go index 2df99d3..ce0f8e8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -42,6 +42,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newInitCmd(&repoPath)) rootCmd.AddCommand(newCloneCmd(&repoPath)) rootCmd.AddCommand(newAddCmd(&repoPath)) + rootCmd.AddCommand(newCreateCmd(&repoPath)) rootCmd.AddCommand(newMoveCmd(&repoPath)) rootCmd.AddCommand(newRemoveCmd(&repoPath)) rootCmd.AddCommand(newForgetCmd(&repoPath)) diff --git a/cmd/root_test.go b/cmd/root_test.go index 9f20f02..8e5b8f6 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -151,7 +151,7 @@ func TestNewRootCommand(t *testing.T) { t.Run("all_subcommands_registered", func(t *testing.T) { root := cmd.NewRootCommand() want := []string{ - "init", "clone", "add", "move", "remove", "forget", + "init", "clone", "add", "create", "move", "remove", "forget", "list", "status", "diff", "commit", "push", "pull", "restore", "update", "doctor", "format", "bootstrap", "project", diff --git a/internal/lnkerror/error.go b/internal/lnkerror/error.go index c980f9b..6f97e91 100644 --- a/internal/lnkerror/error.go +++ b/internal/lnkerror/error.go @@ -28,6 +28,8 @@ var ( ErrEmptyPattern = errors.New("pattern is empty") ErrSyncFailed = errors.New("some files failed to sync") ErrForeignHook = errors.New("existing hook not managed by lnk") + ErrPathExists = errors.New("path already exists") + ErrMixedCreateTypes = errors.New("cannot mix files and directories in one invocation") ) // Error wraps a sentinel error with optional context for display. diff --git a/service/create.go b/service/create.go new file mode 100644 index 0000000..e2f31d3 --- /dev/null +++ b/service/create.go @@ -0,0 +1,221 @@ +package service + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/polymorcodeus/lnk/internal/filemanager" + "github.com/polymorcodeus/lnk/internal/fs" + "github.com/polymorcodeus/lnk/internal/gitboundary" + "github.com/polymorcodeus/lnk/internal/lnkerror" +) + +const lnkKeepFileName = ".lnkkeep" + +// CreateOptions configures the Create command. +type CreateOptions struct { + // AsDir forces every path to be treated as a directory. + AsDir bool +} + +// Create creates empty files or directories and tracks them in common or one +// host scope. All paths must be of the same kind within a single invocation. +func (s *Service) Create(ctx context.Context, host string, paths []string, opts CreateOptions) error { + if err := s.requireGitRepo(); err != nil { + return err + } + if len(paths) == 0 { + return lnkerror.Wrap(lnkerror.ErrNoPaths) + } + + host = NormalizeHost(host) + seen := make(map[string]struct{}, len(paths)) + var files []filemanager.FileToTrack + + for _, input := range paths { + file, err := s.homeRelativePath(input) + if err != nil { + return err + } + if _, ok := seen[file.RelativePath]; ok { + return lnkerror.WithPath(lnkerror.ErrDuplicatePath, file.RelativePath) + } + seen[file.RelativePath] = struct{}{} + + checkPath, err := existingAncestor(file.AbsPath) + if err != nil { + return lnkerror.WithPathAndSuggestion(fs.ErrFileCheck, file.AbsPath, "check file permissions and try again") + } + inside, gitRoot, err := gitboundary.IsInsideGitRepo(ctx, checkPath) + if err != nil { + return err + } + if inside { + return lnkerror.WithPathAndSuggestion(lnkerror.ErrInsideGitRepo, file.RelativePath, fmt.Sprintf("inside git repo %s; use 'lnk project add' from within the project", gitRoot)) + } + + owner, err := s.findOwner(file.RelativePath) + if err != nil { + return err + } + if owner != nil { + return lnkerror.WithPathAndSuggestion(lnkerror.ErrAlreadyManaged, file.RelativePath, fmt.Sprintf("already managed in scope %s", owner.Host)) + } + + ancestor, err := s.findAncestorOwner(file.RelativePath) + if err != nil { + return err + } + if ancestor != nil { + return lnkerror.WithPathAndSuggestion(lnkerror.ErrAlreadyManaged, file.RelativePath, fmt.Sprintf("inside a directory already managed in scope %s", ancestor.Host)) + } + + if err := createPath(file.AbsPath, opts.AsDir); err != nil { + return err + } + + files = append(files, filemanager.FileToTrack{ + AbsPath: file.AbsPath, + RelativePath: file.RelativePath, + }) + } + + fm, err := s.fileManager(host) + if err != nil { + return err + } + + addResult, err := fm.AddMultiple(files) + if err != nil { + return err + } + + if err := s.stagePaths(ctx, addResult.StagePaths...); err != nil { + return err + } + + pathCommit := strings.Join(addResult.StagePaths, "\n") + if err := s.commit(ctx, fmt.Sprintf("lnk: created and added to %s\n%s", host, pathCommit)); err != nil { + fm.RollbackAll(addResult.Rollback) + return err + } + + return nil +} + +// createPath creates an empty file or directory at absPath. For directories it +// also writes a .lnkkeep placeholder so git tracks the empty directory. +func createPath(absPath string, asDir bool) error { + info, err := os.Lstat(absPath) + if err == nil { + if info.Mode()&os.ModeSymlink != 0 { + return lnkerror.WithPathAndSuggestion(fs.ErrUnsupportedType, absPath, "lnk cannot create over a symlink") + } + if asDir { + if !info.IsDir() { + return lnkerror.WithPathAndSuggestion(fs.ErrUnsupportedType, absPath, "path exists and is not a directory") + } + empty, err := isDirEmptyOrKeepOnly(absPath) + if err != nil { + return lnkerror.WithPathAndSuggestion(fs.ErrFileCheck, absPath, "check file permissions and try again") + } + if !empty { + return lnkerror.WithPathAndSuggestion(lnkerror.ErrPathExists, absPath, "directory is not empty; use 'lnk add' to manage existing directories") + } + } else { + return lnkerror.WithPathAndSuggestion(lnkerror.ErrPathExists, absPath, "use 'lnk add' to manage existing files") + } + } else if !errors.Is(err, os.ErrNotExist) { + return lnkerror.WithPathAndSuggestion(fs.ErrFileCheck, absPath, "check file permissions and try again") + } + + if asDir { + if err := os.MkdirAll(absPath, 0o755); err != nil { + return lnkerror.WithPathAndSuggestion(fs.ErrDirCreate, absPath, "check permissions and available disk space") + } + keepPath := filepath.Join(absPath, lnkKeepFileName) + if _, err := os.Lstat(keepPath); errors.Is(err, os.ErrNotExist) { + if err := os.WriteFile(keepPath, []byte{}, 0o644); err != nil { + return lnkerror.WithPathAndSuggestion(fs.ErrFileCheck, keepPath, "check file permissions and available disk space") + } + } + return nil + } + + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return lnkerror.WithPathAndSuggestion(fs.ErrDirCreate, filepath.Dir(absPath), "check permissions and available disk space") + } + + f, err := os.OpenFile(absPath, os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + if errors.Is(err, os.ErrExist) { + return lnkerror.WithPathAndSuggestion(lnkerror.ErrPathExists, absPath, "use 'lnk add' to manage existing files") + } + return lnkerror.WithPathAndSuggestion(fs.ErrFileCheck, absPath, "check file permissions and available disk space") + } + _ = f.Close() + return nil +} + +// existingAncestor walks up from absPath until it finds an existing file or +// directory and returns the path to check for git-boundary membership. For a +// file it returns the file itself; for a missing path it returns the deepest +// existing parent directory. +func existingAncestor(absPath string) (string, error) { + for { + info, err := os.Lstat(absPath) + if err == nil { + if info.IsDir() { + return absPath, nil + } + return filepath.Dir(absPath), nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", err + } + parent := filepath.Dir(absPath) + if parent == absPath { + return "", err + } + absPath = parent + } +} + +// isDirEmptyOrKeepOnly reports whether dir contains no entries, or only a +// .lnkkeep placeholder. +func isDirEmptyOrKeepOnly(dir string) (bool, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return false, err + } + for _, entry := range entries { + if entry.Name() != lnkKeepFileName { + return false, nil + } + } + return true, nil +} + +// findAncestorOwner returns the first scope that manages an ancestor of the +// given relative path, or nil if none. +func (s *Service) findAncestorOwner(relativePath string) (*owner, error) { + for { + dir := filepath.Dir(relativePath) + if dir == relativePath || dir == "." { + break + } + relativePath = dir + owner, err := s.findOwner(relativePath) + if err != nil { + return nil, err + } + if owner != nil { + return owner, nil + } + } + return nil, nil +} diff --git a/service/create_test.go b/service/create_test.go new file mode 100644 index 0000000..eef6dcb --- /dev/null +++ b/service/create_test.go @@ -0,0 +1,317 @@ +package service_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/polymorcodeus/lnk/internal/fs" + "github.com/polymorcodeus/lnk/internal/lnkerror" + "github.com/polymorcodeus/lnk/internal/testhelpers" + "github.com/polymorcodeus/lnk/service" +) + +// ---------- Success cases ---------- + +func TestCreate_SingleFile(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoPath := svc.RepoPath() + + filePath := filepath.Join(home, ".bashrc") + if err := svc.Create(context.Background(), "", []string{filePath}, service.CreateOptions{}); err != nil { + t.Fatalf("Create: %v", err) + } + + storagePath := filepath.Join(repoPath, "common.lnk", ".bashrc") + testhelpers.AssertSymlink(t, filePath, storagePath) + testhelpers.AssertTracked(t, repoPath, ".bashrc") + + commits := testhelpers.GitLog(t, repoPath) + if len(commits) < 2 { + t.Errorf("expected at least 2 commits (init + create), got %d", len(commits)) + } +} + +func TestCreate_MultipleFiles(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoPath := svc.RepoPath() + + paths := []string{ + filepath.Join(home, ".bashrc"), + filepath.Join(home, ".vimrc"), + } + if err := svc.Create(context.Background(), "", paths, service.CreateOptions{}); err != nil { + t.Fatalf("Create: %v", err) + } + + for _, p := range paths { + rel := filepath.Base(p) + storagePath := filepath.Join(repoPath, "common.lnk", rel) + testhelpers.AssertSymlink(t, p, storagePath) + testhelpers.AssertTracked(t, repoPath, rel) + } + + commits := testhelpers.GitLog(t, repoPath) + if len(commits) != 2 { + t.Errorf("expected 2 commits (init + create), got %d", len(commits)) + } +} + +func TestCreate_DirectoryWithDirFlag(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoPath := svc.RepoPath() + + dirPath := filepath.Join(home, ".config", "awesome") + if err := svc.Create(context.Background(), "", []string{dirPath}, service.CreateOptions{AsDir: true}); err != nil { + t.Fatalf("Create: %v", err) + } + + storagePath := filepath.Join(repoPath, "common.lnk", ".config", "awesome") + testhelpers.AssertSymlink(t, dirPath, storagePath) + testhelpers.AssertTracked(t, repoPath, ".config/awesome") + + keepPath := filepath.Join(storagePath, ".lnkkeep") + if !testhelpers.FileExists(t, keepPath) { + t.Errorf("expected .lnkkeep at %q", keepPath) + } +} + +func TestCreate_DirectoryWithTrailingSlash(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoPath := svc.RepoPath() + + dirPath := filepath.Join(home, ".config", "awesome") + string(filepath.Separator) + if err := svc.Create(context.Background(), "", []string{dirPath}, service.CreateOptions{AsDir: true}); err != nil { + t.Fatalf("Create: %v", err) + } + + storagePath := filepath.Join(repoPath, "common.lnk", ".config", "awesome") + livePath := filepath.Join(home, ".config", "awesome") + testhelpers.AssertSymlink(t, livePath, storagePath) + testhelpers.AssertTracked(t, repoPath, ".config/awesome") +} + +func TestCreate_NestedFile(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoPath := svc.RepoPath() + + filePath := filepath.Join(home, ".config", "git", "config") + if err := svc.Create(context.Background(), "", []string{filePath}, service.CreateOptions{}); err != nil { + t.Fatalf("Create: %v", err) + } + + storagePath := filepath.Join(repoPath, "common.lnk", ".config", "git", "config") + testhelpers.AssertSymlink(t, filePath, storagePath) + testhelpers.AssertTracked(t, repoPath, ".config/git/config") +} + +// ---------- Host scope tests ---------- + +func TestCreate_HostScope_ExplicitHost(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoPath := svc.RepoPath() + + filePath := filepath.Join(home, ".bashrc") + if err := svc.Create(context.Background(), "testhost", []string{filePath}, service.CreateOptions{}); err != nil { + t.Fatalf("Create: %v", err) + } + + hostStorage := filepath.Join(repoPath, "testhost.lnk", ".bashrc") + testhelpers.AssertSymlink(t, filePath, hostStorage) + testhelpers.AssertTrackedInScope(t, repoPath, "testhost", ".bashrc") + testhelpers.AssertNotTracked(t, repoPath, ".bashrc") +} + +// ---------- V1 format tests ---------- + +func TestCreate_V1_SingleFile(t *testing.T) { + svc, home := testhelpers.TestHomeV1(t) + repoPath := svc.RepoPath() + + filePath := filepath.Join(home, ".bashrc") + if err := svc.Create(context.Background(), "", []string{filePath}, service.CreateOptions{}); err != nil { + t.Fatalf("Create v1: %v", err) + } + + storagePath := filepath.Join(repoPath, ".bashrc") + testhelpers.AssertSymlink(t, filePath, storagePath) + testhelpers.AssertTracked(t, repoPath, ".bashrc") +} + +// ---------- Failure cases ---------- + +func TestCreate_ExistingFile(t *testing.T) { + svc, home := testhelpers.TestHome(t) + + filePath := filepath.Join(home, ".bashrc") + testhelpers.MakeFile(t, filePath, "# bashrc") + + err := svc.Create(context.Background(), "", []string{filePath}, service.CreateOptions{}) + if err == nil { + t.Fatal("expected error creating existing file, got nil") + } + if !errors.Is(err, lnkerror.ErrPathExists) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrPathExists) + } +} + +func TestCreate_ExistingNonEmptyDirectory(t *testing.T) { + svc, home := testhelpers.TestHome(t) + + dirPath := filepath.Join(home, ".config", "awesome") + testhelpers.MakeDir(t, dirPath) + testhelpers.MakeFile(t, filepath.Join(dirPath, "existing.conf"), "content") + + err := svc.Create(context.Background(), "", []string{dirPath}, service.CreateOptions{AsDir: true}) + if err == nil { + t.Fatal("expected error creating non-empty directory, got nil") + } + if !errors.Is(err, lnkerror.ErrPathExists) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrPathExists) + } +} + +func TestCreate_ExistingEmptyDirectory(t *testing.T) { + svc, home := testhelpers.TestHome(t) + repoPath := svc.RepoPath() + + dirPath := filepath.Join(home, ".config", "awesome") + testhelpers.MakeDir(t, dirPath) + + if err := svc.Create(context.Background(), "", []string{dirPath}, service.CreateOptions{AsDir: true}); err != nil { + t.Fatalf("Create empty dir: %v", err) + } + + storagePath := filepath.Join(repoPath, "common.lnk", ".config", "awesome") + testhelpers.AssertSymlink(t, dirPath, storagePath) + testhelpers.AssertTracked(t, repoPath, ".config/awesome") +} + +func TestCreate_PathIsSymlink(t *testing.T) { + svc, home := testhelpers.TestHome(t) + + filePath := filepath.Join(home, ".bashrc") + targetPath := filepath.Join(home, ".real-bashrc") + testhelpers.MakeFile(t, targetPath, "# real bashrc") + if err := os.Symlink(targetPath, filePath); err != nil { + t.Fatal(err) + } + + err := svc.Create(context.Background(), "", []string{filePath}, service.CreateOptions{}) + if err == nil { + t.Fatal("expected error creating over a symlink, got nil") + } + if !errors.Is(err, fs.ErrUnsupportedType) { + t.Errorf("error = %v, want %v", err, fs.ErrUnsupportedType) + } +} + +func TestCreate_InsideManagedDirectory(t *testing.T) { + svc, home := testhelpers.TestHome(t) + + parentDir := filepath.Join(home, ".config", "awesome") + testhelpers.MakeDir(t, parentDir) + if err := svc.Add(context.Background(), "", []string{parentDir}); err != nil { + t.Fatalf("Add parent dir: %v", err) + } + + childPath := filepath.Join(home, ".config", "awesome", "child.conf") + err := svc.Create(context.Background(), "", []string{childPath}, service.CreateOptions{}) + if err == nil { + t.Fatal("expected error creating inside managed directory, got nil") + } + if !errors.Is(err, lnkerror.ErrAlreadyManaged) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrAlreadyManaged) + } +} + +func TestCreate_AlreadyManaged(t *testing.T) { + svc, home := testhelpers.TestHome(t) + + filePath := filepath.Join(home, ".bashrc") + testhelpers.MakeFile(t, filePath, "# bashrc") + if err := svc.Add(context.Background(), "", []string{filePath}); err != nil { + t.Fatalf("Add: %v", err) + } + + err := svc.Create(context.Background(), "", []string{filePath}, service.CreateOptions{}) + if err == nil { + t.Fatal("expected error creating already-managed path, got nil") + } + if !errors.Is(err, lnkerror.ErrAlreadyManaged) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrAlreadyManaged) + } +} + +func TestCreate_PathOutsideHome(t *testing.T) { + svc, _ := testhelpers.TestHome(t) + + outsidePath := filepath.Join(os.TempDir(), "lnk-test-outside-create") + defer os.Remove(outsidePath) + + err := svc.Create(context.Background(), "", []string{outsidePath}, service.CreateOptions{}) + if err == nil { + t.Fatal("expected error for path outside $HOME, got nil") + } + if !errors.Is(err, lnkerror.ErrNotInHome) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrNotInHome) + } +} + +func TestCreate_InsideGitRepo(t *testing.T) { + svc, home := testhelpers.TestHome(t) + + projectRoot := filepath.Join(home, "project") + testhelpers.MakeDir(t, projectRoot) + testhelpers.InitGitRepo(t, projectRoot) + + filePath := filepath.Join(projectRoot, "config") + err := svc.Create(context.Background(), "", []string{filePath}, service.CreateOptions{}) + if err == nil { + t.Fatal("expected error creating inside a git repo, got nil") + } + if !errors.Is(err, lnkerror.ErrInsideGitRepo) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrInsideGitRepo) + } +} + +func TestCreate_NotInitialized(t *testing.T) { + svc, repoPath := testhelpers.NewTestRepo(t) + _ = repoPath + + filePath := filepath.Join("/nonexistent", "home", ".bashrc") + err := svc.Create(context.Background(), "", []string{filePath}, service.CreateOptions{}) + if err == nil { + t.Fatal("expected error when repo not initialized, got nil") + } + if !errors.Is(err, lnkerror.ErrNotInitialized) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrNotInitialized) + } +} + +func TestCreate_DuplicateInSameCall(t *testing.T) { + svc, home := testhelpers.TestHome(t) + + filePath := filepath.Join(home, ".bashrc") + err := svc.Create(context.Background(), "", []string{filePath, filePath}, service.CreateOptions{}) + if err == nil { + t.Fatal("expected error for duplicate path in same call, got nil") + } + if !errors.Is(err, lnkerror.ErrDuplicatePath) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrDuplicatePath) + } +} + +func TestCreate_EmptyPaths(t *testing.T) { + svc, _ := testhelpers.TestHome(t) + + err := svc.Create(context.Background(), "", []string{}, service.CreateOptions{}) + if err == nil { + t.Fatal("expected error for empty paths, got nil") + } + if !errors.Is(err, lnkerror.ErrNoPaths) { + t.Errorf("error = %v, want %v", err, lnkerror.ErrNoPaths) + } +} From 03d66fca526192676807ec53649f155c9a20a1fa Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sat, 29 Aug 2026 20:24:00 -0500 Subject: [PATCH 2/3] docs: updated README --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 169fb96..bb5ef57 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ Track dotfiles across machines with one command. Lnk moves files into a Git repo ```bash lnk init # create a local repo lnk clone git@github.com:you/dotfiles.git # clone a remote repo -lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # track files +lnk create ~/.vimrc ~/.bashrc ~/.gitconfig # create empty files and track them +lnk create --dir ~/.config/awesome # create empty directory and track it +lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # track existing files lnk add --host work ~/.ssh/config # per-machine config lnk push # push to remote lnk update # pull and restore symlinks @@ -102,6 +104,17 @@ Common files live at the repo root (v1) or under `common.lnk/` (v2). Host-specif ## Features +### Create files and directories + +```bash +lnk create ~/.vimrc ~/.bashrc # create empty files and track them +lnk create --dir ~/.config/awesome # create empty directory and track it +lnk create ~/.config/awesome/ # same as --dir (trailing slash) +lnk create --host work ~/.ssh/config # create and track in host scope +``` + +All paths in one `create` invocation must be the same kind: either all files or all directories. Use `--dir` or a trailing slash to request directories. + ### Add files ```bash @@ -271,6 +284,7 @@ man man/lnk-project-push.1 # read a generated page | `init` | Create or adopt a local lnk repo | | `clone [--bootstrap]` | Clone a remote lnk repo | | `add [--host H] ` | Track files (move to repo + symlink) | +| `create [--dir] [--host H] ` | Create empty files or directories and track them | | `move (--to-common \| --to-host H)` | Move a tracked path between scopes | | `remove [--host H] ` | Stop managing, restore file locally | | `forget [--host H] ` | Stop tracking, keep stored repo copy | From 6e4ab403b00f5eae698c904a74baa30e92f7da8e Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sat, 29 Aug 2026 20:24:17 -0500 Subject: [PATCH 3/3] chore: version bump --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index aaf7425..8721bbc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v2.3.1 +v2.4.0