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
16 changes: 15 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -271,6 +284,7 @@ man man/lnk-project-push.1 # read a generated page
| `init` | Create or adopt a local lnk repo |
| `clone <url> [--bootstrap]` | Clone a remote lnk repo |
| `add [--host H] <path...>` | Track files (move to repo + symlink) |
| `create [--dir] [--host H] <path...>` | Create empty files or directories and track them |
| `move <path> (--to-common \| --to-host H)` | Move a tracked path between scopes |
| `remove [--host H] <path>` | Stop managing, restore file locally |
| `forget [--host H] <path>` | Stop tracking, keep stored repo copy |
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
v2.3.1
v2.4.0
77 changes: 77 additions & 0 deletions cmd/create.go
Original file line numberDiff line numberDiff line change
@@ -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] <path...>",
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, "\\"))
}
140 changes: 140 additions & 0 deletions cmd/create_test.go
Original file line numberDiff line numberDiff line change
@@ -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")
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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))
Expand Down
2 changes: 1 addition & 1 deletion cmd/root_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions internal/lnkerror/error.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
Loading
Loading