Skip to content

Commit 36d76c8

Browse files
committed
feat(add): implement multiple file addition with atomic operation
1 parent 6de3877 commit 36d76c8

File tree

4 files changed

+567
-25
lines changed

4 files changed

+567
-25
lines changed

cmd/add.go

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,60 @@ import (
99

1010
func newAddCmd() *cobra.Command {
1111
cmd := &cobra.Command{
12-
Use: "add <file>",
13-
Short: "✨ Add a file to lnk management",
14-
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
15-
Args: cobra.ExactArgs(1),
12+
Use: "add <file>...",
13+
Short: "✨ Add files to lnk management",
14+
Long: "Moves files to the lnk repository and creates symlinks in their place. Supports multiple files.",
15+
Args: cobra.MinimumNArgs(1),
1616
SilenceUsage: true,
1717
SilenceErrors: true,
1818
RunE: func(cmd *cobra.Command, args []string) error {
19-
filePath := args[0]
2019
host, _ := cmd.Flags().GetString("host")
21-
2220
lnk := core.NewLnk(core.WithHost(host))
2321

24-
if err := lnk.Add(filePath); err != nil {
25-
return err
22+
// Use appropriate method based on number of files
23+
if len(args) == 1 {
24+
// Single file - use existing Add method for backward compatibility
25+
if err := lnk.Add(args[0]); err != nil {
26+
return err
27+
}
28+
} else {
29+
// Multiple files - use AddMultiple for atomic operation
30+
if err := lnk.AddMultiple(args); err != nil {
31+
return err
32+
}
2633
}
2734

28-
basename := filepath.Base(filePath)
29-
if host != "" {
30-
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host)
31-
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", filePath, host, filePath)
35+
// Display results
36+
if len(args) == 1 {
37+
// Single file - maintain existing output format for backward compatibility
38+
filePath := args[0]
39+
basename := filepath.Base(filePath)
40+
if host != "" {
41+
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host)
42+
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", filePath, host, filePath)
43+
} else {
44+
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
45+
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, filePath)
46+
}
3247
} else {
33-
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
34-
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, filePath)
48+
// Multiple files - show summary
49+
if host != "" {
50+
printf(cmd, "✨ \033[1mAdded %d items to lnk (host: %s)\033[0m\n", len(args), host)
51+
} else {
52+
printf(cmd, "✨ \033[1mAdded %d items to lnk\033[0m\n", len(args))
53+
}
54+
55+
// List each added file
56+
for _, filePath := range args {
57+
basename := filepath.Base(filePath)
58+
if host != "" {
59+
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host)
60+
} else {
61+
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename)
62+
}
63+
}
3564
}
65+
3666
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
3767
return nil
3868
},

cmd/root_test.go

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ func (suite *CLITestSuite) TestErrorHandling() {
277277
name: "add help",
278278
args: []string{"add", "--help"},
279279
wantErr: false,
280-
outContains: "Moves a file to the lnk repository",
280+
outContains: "Moves files to the lnk repository",
281281
},
282282
{
283283
name: "list help",
@@ -790,8 +790,8 @@ func (suite *CLITestSuite) TestInitWithBootstrap() {
790790
err := os.MkdirAll(remoteDir, 0755)
791791
suite.Require().NoError(err)
792792

793-
// Initialize git repo in remote
794-
cmd := exec.Command("git", "init", "--bare")
793+
// Initialize git repo in remote with main branch
794+
cmd := exec.Command("git", "init", "--bare", "--initial-branch=main")
795795
cmd.Dir = remoteDir
796796
err = cmd.Run()
797797
suite.Require().NoError(err)
@@ -835,7 +835,7 @@ touch remote-bootstrap-ran.txt
835835
err = cmd.Run()
836836
suite.Require().NoError(err)
837837

838-
cmd = exec.Command("git", "push", "origin", "master")
838+
cmd = exec.Command("git", "push", "origin", "main")
839839
cmd.Dir = workingDir
840840
err = cmd.Run()
841841
suite.Require().NoError(err)
@@ -863,8 +863,8 @@ func (suite *CLITestSuite) TestInitWithBootstrapDisabled() {
863863
err := os.MkdirAll(remoteDir, 0755)
864864
suite.Require().NoError(err)
865865

866-
// Initialize git repo in remote
867-
cmd := exec.Command("git", "init", "--bare")
866+
// Initialize git repo in remote with main branch
867+
cmd := exec.Command("git", "init", "--bare", "--initial-branch=main")
868868
cmd.Dir = remoteDir
869869
err = cmd.Run()
870870
suite.Require().NoError(err)
@@ -898,7 +898,7 @@ touch should-not-exist.txt
898898
err = cmd.Run()
899899
suite.Require().NoError(err)
900900

901-
cmd = exec.Command("git", "push", "origin", "master")
901+
cmd = exec.Command("git", "push", "origin", "main")
902902
cmd.Dir = workingDir
903903
err = cmd.Run()
904904
suite.Require().NoError(err)
@@ -917,6 +917,101 @@ touch should-not-exist.txt
917917
suite.NoFileExists(markerFile)
918918
}
919919

920+
func (suite *CLITestSuite) TestAddCommandMultipleFiles() {
921+
// Initialize repository
922+
err := suite.runCommand("init")
923+
suite.Require().NoError(err)
924+
suite.stdout.Reset()
925+
926+
// Create multiple test files
927+
testFile1 := filepath.Join(suite.tempDir, ".bashrc")
928+
err = os.WriteFile(testFile1, []byte("export PATH1"), 0644)
929+
suite.Require().NoError(err)
930+
931+
testFile2 := filepath.Join(suite.tempDir, ".vimrc")
932+
err = os.WriteFile(testFile2, []byte("set number"), 0644)
933+
suite.Require().NoError(err)
934+
935+
testFile3 := filepath.Join(suite.tempDir, ".gitconfig")
936+
err = os.WriteFile(testFile3, []byte("[user]\n name = test"), 0644)
937+
suite.Require().NoError(err)
938+
939+
// Test add command with multiple files - should succeed
940+
err = suite.runCommand("add", testFile1, testFile2, testFile3)
941+
suite.NoError(err, "Adding multiple files should succeed")
942+
943+
// Check output shows all files were added
944+
output := suite.stdout.String()
945+
suite.Contains(output, "Added 3 items to lnk")
946+
suite.Contains(output, ".bashrc")
947+
suite.Contains(output, ".vimrc")
948+
suite.Contains(output, ".gitconfig")
949+
950+
// Verify all files are now symlinks
951+
for _, file := range []string{testFile1, testFile2, testFile3} {
952+
info, err := os.Lstat(file)
953+
suite.NoError(err)
954+
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
955+
}
956+
957+
// Verify all files exist in storage
958+
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
959+
suite.FileExists(filepath.Join(lnkDir, ".bashrc"))
960+
suite.FileExists(filepath.Join(lnkDir, ".vimrc"))
961+
suite.FileExists(filepath.Join(lnkDir, ".gitconfig"))
962+
963+
// Verify .lnk file contains all entries
964+
lnkFile := filepath.Join(lnkDir, ".lnk")
965+
lnkContent, err := os.ReadFile(lnkFile)
966+
suite.NoError(err)
967+
suite.Equal(".bashrc\n.gitconfig\n.vimrc\n", string(lnkContent))
968+
}
969+
970+
func (suite *CLITestSuite) TestAddCommandMixedTypes() {
971+
// Initialize repository
972+
err := suite.runCommand("init")
973+
suite.Require().NoError(err)
974+
suite.stdout.Reset()
975+
976+
// Create a file
977+
testFile := filepath.Join(suite.tempDir, ".vimrc")
978+
err = os.WriteFile(testFile, []byte("set number"), 0644)
979+
suite.Require().NoError(err)
980+
981+
// Create a directory with content
982+
testDir := filepath.Join(suite.tempDir, ".config", "git")
983+
err = os.MkdirAll(testDir, 0755)
984+
suite.Require().NoError(err)
985+
configFile := filepath.Join(testDir, "config")
986+
err = os.WriteFile(configFile, []byte("[user]"), 0644)
987+
suite.Require().NoError(err)
988+
989+
// Test add command with mixed files and directories - should succeed
990+
err = suite.runCommand("add", testFile, testDir)
991+
suite.NoError(err, "Adding mixed files and directories should succeed")
992+
993+
// Check output shows both items were added
994+
output := suite.stdout.String()
995+
suite.Contains(output, "Added 2 items to lnk")
996+
suite.Contains(output, ".vimrc")
997+
suite.Contains(output, "git")
998+
999+
// Verify both are now symlinks
1000+
info1, err := os.Lstat(testFile)
1001+
suite.NoError(err)
1002+
suite.Equal(os.ModeSymlink, info1.Mode()&os.ModeSymlink)
1003+
1004+
info2, err := os.Lstat(testDir)
1005+
suite.NoError(err)
1006+
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink)
1007+
1008+
// Verify storage
1009+
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
1010+
suite.FileExists(filepath.Join(lnkDir, ".vimrc"))
1011+
suite.DirExists(filepath.Join(lnkDir, ".config", "git"))
1012+
suite.FileExists(filepath.Join(lnkDir, ".config", "git", "config"))
1013+
}
1014+
9201015
func TestCLISuite(t *testing.T) {
9211016
suite.Run(t, new(CLITestSuite))
9221017
}

internal/core/lnk.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,153 @@ func (l *Lnk) Add(filePath string) error {
257257
return nil
258258
}
259259

260+
// AddMultiple adds multiple files or directories to the repository in a single transaction
261+
func (l *Lnk) AddMultiple(paths []string) error {
262+
if len(paths) == 0 {
263+
return nil
264+
}
265+
266+
// Phase 1: Validate all paths first
267+
var relativePaths []string
268+
var absolutePaths []string
269+
var infos []os.FileInfo
270+
271+
for _, filePath := range paths {
272+
// Validate the file or directory
273+
if err := l.fs.ValidateFileForAdd(filePath); err != nil {
274+
return fmt.Errorf("validation failed for %s: %w", filePath, err)
275+
}
276+
277+
// Get absolute path
278+
absPath, err := filepath.Abs(filePath)
279+
if err != nil {
280+
return fmt.Errorf("failed to get absolute path for %s: %w", filePath, err)
281+
}
282+
283+
// Get relative path for tracking
284+
relativePath, err := getRelativePath(absPath)
285+
if err != nil {
286+
return fmt.Errorf("failed to get relative path for %s: %w", filePath, err)
287+
}
288+
289+
// Check if this relative path is already managed
290+
managedItems, err := l.getManagedItems()
291+
if err != nil {
292+
return fmt.Errorf("failed to get managed items: %w", err)
293+
}
294+
for _, item := range managedItems {
295+
if item == relativePath {
296+
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
297+
}
298+
}
299+
300+
// Get file info
301+
info, err := os.Stat(absPath)
302+
if err != nil {
303+
return fmt.Errorf("failed to stat path %s: %w", filePath, err)
304+
}
305+
306+
relativePaths = append(relativePaths, relativePath)
307+
absolutePaths = append(absolutePaths, absPath)
308+
infos = append(infos, info)
309+
}
310+
311+
// Phase 2: Process all files - move to repository and create symlinks
312+
var rollbackActions []func() error
313+
314+
for i, absPath := range absolutePaths {
315+
relativePath := relativePaths[i]
316+
info := infos[i]
317+
318+
// Generate repository path from relative path
319+
storagePath := l.getHostStoragePath()
320+
destPath := filepath.Join(storagePath, relativePath)
321+
322+
// Ensure destination directory exists
323+
destDir := filepath.Dir(destPath)
324+
if err := os.MkdirAll(destDir, 0755); err != nil {
325+
// Rollback previous operations
326+
l.rollbackOperations(rollbackActions)
327+
return fmt.Errorf("failed to create destination directory: %w", err)
328+
}
329+
330+
// Move to repository
331+
if err := l.fs.Move(absPath, destPath, info); err != nil {
332+
// Rollback previous operations
333+
l.rollbackOperations(rollbackActions)
334+
return fmt.Errorf("failed to move %s: %w", absPath, err)
335+
}
336+
337+
// Create symlink
338+
if err := l.fs.CreateSymlink(destPath, absPath); err != nil {
339+
// Try to restore the file we just moved, then rollback others
340+
_ = l.fs.Move(destPath, absPath, info)
341+
l.rollbackOperations(rollbackActions)
342+
return fmt.Errorf("failed to create symlink for %s: %w", absPath, err)
343+
}
344+
345+
// Add to tracking
346+
if err := l.addManagedItem(relativePath); err != nil {
347+
// Restore this file and rollback others
348+
_ = os.Remove(absPath)
349+
_ = l.fs.Move(destPath, absPath, info)
350+
l.rollbackOperations(rollbackActions)
351+
return fmt.Errorf("failed to update tracking file for %s: %w", absPath, err)
352+
}
353+
354+
// Add rollback action for this file
355+
rollbackAction := l.createRollbackAction(absPath, destPath, relativePath, info)
356+
rollbackActions = append(rollbackActions, rollbackAction)
357+
}
358+
359+
// Phase 3: Git operations - add all files and create single commit
360+
for i, relativePath := range relativePaths {
361+
// For host-specific files, we need to add the relative path from repo root
362+
gitPath := relativePath
363+
if l.host != "" {
364+
gitPath = filepath.Join(l.host+".lnk", relativePath)
365+
}
366+
if err := l.git.Add(gitPath); err != nil {
367+
// Rollback all operations
368+
l.rollbackOperations(rollbackActions)
369+
return fmt.Errorf("failed to add %s to git: %w", absolutePaths[i], err)
370+
}
371+
}
372+
373+
// Add .lnk file to the same commit
374+
if err := l.git.Add(l.getLnkFileName()); err != nil {
375+
// Rollback all operations
376+
l.rollbackOperations(rollbackActions)
377+
return fmt.Errorf("failed to add tracking file to git: %w", err)
378+
}
379+
380+
// Commit all changes together
381+
commitMessage := fmt.Sprintf("lnk: added %d files", len(paths))
382+
if err := l.git.Commit(commitMessage); err != nil {
383+
// Rollback all operations
384+
l.rollbackOperations(rollbackActions)
385+
return fmt.Errorf("failed to commit changes: %w", err)
386+
}
387+
388+
return nil
389+
}
390+
391+
// createRollbackAction creates a rollback function for a single file operation
392+
func (l *Lnk) createRollbackAction(absPath, destPath, relativePath string, info os.FileInfo) func() error {
393+
return func() error {
394+
_ = os.Remove(absPath)
395+
_ = l.removeManagedItem(relativePath)
396+
return l.fs.Move(destPath, absPath, info)
397+
}
398+
}
399+
400+
// rollbackOperations executes rollback actions in reverse order
401+
func (l *Lnk) rollbackOperations(rollbackActions []func() error) {
402+
for i := len(rollbackActions) - 1; i >= 0; i-- {
403+
_ = rollbackActions[i]()
404+
}
405+
}
406+
260407
// Remove removes a symlink and restores the original file or directory
261408
func (l *Lnk) Remove(filePath string) error {
262409
// Get absolute path

0 commit comments

Comments
 (0)