feat(add): implement multiple file addition with atomic operation

This commit is contained in:
Yar Kravtsov
2025-07-29 08:32:33 +03:00
parent 6de387797e
commit 36d76c881c
4 changed files with 567 additions and 25 deletions

View File

@@ -9,22 +9,33 @@ import (
func newAddCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "add <file>",
Short: "✨ Add a file to lnk management",
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
Args: cobra.ExactArgs(1),
Use: "add <file>...",
Short: "✨ Add files to lnk management",
Long: "Moves files to the lnk repository and creates symlinks in their place. Supports multiple files.",
Args: cobra.MinimumNArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0]
host, _ := cmd.Flags().GetString("host")
lnk := core.NewLnk(core.WithHost(host))
if err := lnk.Add(filePath); err != nil {
// Use appropriate method based on number of files
if len(args) == 1 {
// Single file - use existing Add method for backward compatibility
if err := lnk.Add(args[0]); err != nil {
return err
}
} else {
// Multiple files - use AddMultiple for atomic operation
if err := lnk.AddMultiple(args); err != nil {
return err
}
}
// Display results
if len(args) == 1 {
// Single file - maintain existing output format for backward compatibility
filePath := args[0]
basename := filepath.Base(filePath)
if host != "" {
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host)
@@ -33,6 +44,25 @@ func newAddCmd() *cobra.Command {
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, filePath)
}
} else {
// Multiple files - show summary
if host != "" {
printf(cmd, "✨ \033[1mAdded %d items to lnk (host: %s)\033[0m\n", len(args), host)
} else {
printf(cmd, "✨ \033[1mAdded %d items to lnk\033[0m\n", len(args))
}
// List each added file
for _, filePath := range args {
basename := filepath.Base(filePath)
if host != "" {
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host)
} else {
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename)
}
}
}
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
return nil
},

View File

@@ -277,7 +277,7 @@ func (suite *CLITestSuite) TestErrorHandling() {
name: "add help",
args: []string{"add", "--help"},
wantErr: false,
outContains: "Moves a file to the lnk repository",
outContains: "Moves files to the lnk repository",
},
{
name: "list help",
@@ -790,8 +790,8 @@ func (suite *CLITestSuite) TestInitWithBootstrap() {
err := os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
// Initialize git repo in remote
cmd := exec.Command("git", "init", "--bare")
// Initialize git repo in remote with main branch
cmd := exec.Command("git", "init", "--bare", "--initial-branch=main")
cmd.Dir = remoteDir
err = cmd.Run()
suite.Require().NoError(err)
@@ -835,7 +835,7 @@ touch remote-bootstrap-ran.txt
err = cmd.Run()
suite.Require().NoError(err)
cmd = exec.Command("git", "push", "origin", "master")
cmd = exec.Command("git", "push", "origin", "main")
cmd.Dir = workingDir
err = cmd.Run()
suite.Require().NoError(err)
@@ -863,8 +863,8 @@ func (suite *CLITestSuite) TestInitWithBootstrapDisabled() {
err := os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
// Initialize git repo in remote
cmd := exec.Command("git", "init", "--bare")
// Initialize git repo in remote with main branch
cmd := exec.Command("git", "init", "--bare", "--initial-branch=main")
cmd.Dir = remoteDir
err = cmd.Run()
suite.Require().NoError(err)
@@ -898,7 +898,7 @@ touch should-not-exist.txt
err = cmd.Run()
suite.Require().NoError(err)
cmd = exec.Command("git", "push", "origin", "master")
cmd = exec.Command("git", "push", "origin", "main")
cmd.Dir = workingDir
err = cmd.Run()
suite.Require().NoError(err)
@@ -917,6 +917,101 @@ touch should-not-exist.txt
suite.NoFileExists(markerFile)
}
func (suite *CLITestSuite) TestAddCommandMultipleFiles() {
// Initialize repository
err := suite.runCommand("init")
suite.Require().NoError(err)
suite.stdout.Reset()
// Create multiple test files
testFile1 := filepath.Join(suite.tempDir, ".bashrc")
err = os.WriteFile(testFile1, []byte("export PATH1"), 0644)
suite.Require().NoError(err)
testFile2 := filepath.Join(suite.tempDir, ".vimrc")
err = os.WriteFile(testFile2, []byte("set number"), 0644)
suite.Require().NoError(err)
testFile3 := filepath.Join(suite.tempDir, ".gitconfig")
err = os.WriteFile(testFile3, []byte("[user]\n name = test"), 0644)
suite.Require().NoError(err)
// Test add command with multiple files - should succeed
err = suite.runCommand("add", testFile1, testFile2, testFile3)
suite.NoError(err, "Adding multiple files should succeed")
// Check output shows all files were added
output := suite.stdout.String()
suite.Contains(output, "Added 3 items to lnk")
suite.Contains(output, ".bashrc")
suite.Contains(output, ".vimrc")
suite.Contains(output, ".gitconfig")
// Verify all files are now symlinks
for _, file := range []string{testFile1, testFile2, testFile3} {
info, err := os.Lstat(file)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
}
// Verify all files exist in storage
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
suite.FileExists(filepath.Join(lnkDir, ".bashrc"))
suite.FileExists(filepath.Join(lnkDir, ".vimrc"))
suite.FileExists(filepath.Join(lnkDir, ".gitconfig"))
// Verify .lnk file contains all entries
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n.gitconfig\n.vimrc\n", string(lnkContent))
}
func (suite *CLITestSuite) TestAddCommandMixedTypes() {
// Initialize repository
err := suite.runCommand("init")
suite.Require().NoError(err)
suite.stdout.Reset()
// Create a file
testFile := filepath.Join(suite.tempDir, ".vimrc")
err = os.WriteFile(testFile, []byte("set number"), 0644)
suite.Require().NoError(err)
// Create a directory with content
testDir := filepath.Join(suite.tempDir, ".config", "git")
err = os.MkdirAll(testDir, 0755)
suite.Require().NoError(err)
configFile := filepath.Join(testDir, "config")
err = os.WriteFile(configFile, []byte("[user]"), 0644)
suite.Require().NoError(err)
// Test add command with mixed files and directories - should succeed
err = suite.runCommand("add", testFile, testDir)
suite.NoError(err, "Adding mixed files and directories should succeed")
// Check output shows both items were added
output := suite.stdout.String()
suite.Contains(output, "Added 2 items to lnk")
suite.Contains(output, ".vimrc")
suite.Contains(output, "git")
// Verify both are now symlinks
info1, err := os.Lstat(testFile)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info1.Mode()&os.ModeSymlink)
info2, err := os.Lstat(testDir)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink)
// Verify storage
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
suite.FileExists(filepath.Join(lnkDir, ".vimrc"))
suite.DirExists(filepath.Join(lnkDir, ".config", "git"))
suite.FileExists(filepath.Join(lnkDir, ".config", "git", "config"))
}
func TestCLISuite(t *testing.T) {
suite.Run(t, new(CLITestSuite))
}

View File

@@ -257,6 +257,153 @@ func (l *Lnk) Add(filePath string) error {
return nil
}
// AddMultiple adds multiple files or directories to the repository in a single transaction
func (l *Lnk) AddMultiple(paths []string) error {
if len(paths) == 0 {
return nil
}
// Phase 1: Validate all paths first
var relativePaths []string
var absolutePaths []string
var infos []os.FileInfo
for _, filePath := range paths {
// Validate the file or directory
if err := l.fs.ValidateFileForAdd(filePath); err != nil {
return fmt.Errorf("validation failed for %s: %w", filePath, err)
}
// Get absolute path
absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("failed to get absolute path for %s: %w", filePath, err)
}
// Get relative path for tracking
relativePath, err := getRelativePath(absPath)
if err != nil {
return fmt.Errorf("failed to get relative path for %s: %w", filePath, err)
}
// Check if this relative path is already managed
managedItems, err := l.getManagedItems()
if err != nil {
return fmt.Errorf("failed to get managed items: %w", err)
}
for _, item := range managedItems {
if item == relativePath {
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
}
}
// Get file info
info, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("failed to stat path %s: %w", filePath, err)
}
relativePaths = append(relativePaths, relativePath)
absolutePaths = append(absolutePaths, absPath)
infos = append(infos, info)
}
// Phase 2: Process all files - move to repository and create symlinks
var rollbackActions []func() error
for i, absPath := range absolutePaths {
relativePath := relativePaths[i]
info := infos[i]
// Generate repository path from relative path
storagePath := l.getHostStoragePath()
destPath := filepath.Join(storagePath, relativePath)
// Ensure destination directory exists
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
// Rollback previous operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to create destination directory: %w", err)
}
// Move to repository
if err := l.fs.Move(absPath, destPath, info); err != nil {
// Rollback previous operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to move %s: %w", absPath, err)
}
// Create symlink
if err := l.fs.CreateSymlink(destPath, absPath); err != nil {
// Try to restore the file we just moved, then rollback others
_ = l.fs.Move(destPath, absPath, info)
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to create symlink for %s: %w", absPath, err)
}
// Add to tracking
if err := l.addManagedItem(relativePath); err != nil {
// Restore this file and rollback others
_ = os.Remove(absPath)
_ = l.fs.Move(destPath, absPath, info)
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to update tracking file for %s: %w", absPath, err)
}
// Add rollback action for this file
rollbackAction := l.createRollbackAction(absPath, destPath, relativePath, info)
rollbackActions = append(rollbackActions, rollbackAction)
}
// Phase 3: Git operations - add all files and create single commit
for i, relativePath := range relativePaths {
// For host-specific files, we need to add the relative path from repo root
gitPath := relativePath
if l.host != "" {
gitPath = filepath.Join(l.host+".lnk", relativePath)
}
if err := l.git.Add(gitPath); err != nil {
// Rollback all operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to add %s to git: %w", absolutePaths[i], err)
}
}
// Add .lnk file to the same commit
if err := l.git.Add(l.getLnkFileName()); err != nil {
// Rollback all operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to add tracking file to git: %w", err)
}
// Commit all changes together
commitMessage := fmt.Sprintf("lnk: added %d files", len(paths))
if err := l.git.Commit(commitMessage); err != nil {
// Rollback all operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to commit changes: %w", err)
}
return nil
}
// createRollbackAction creates a rollback function for a single file operation
func (l *Lnk) createRollbackAction(absPath, destPath, relativePath string, info os.FileInfo) func() error {
return func() error {
_ = os.Remove(absPath)
_ = l.removeManagedItem(relativePath)
return l.fs.Move(destPath, absPath, info)
}
}
// rollbackOperations executes rollback actions in reverse order
func (l *Lnk) rollbackOperations(rollbackActions []func() error) {
for i := len(rollbackActions) - 1; i >= 0; i-- {
_ = rollbackActions[i]()
}
}
// Remove removes a symlink and restores the original file or directory
func (l *Lnk) Remove(filePath string) error {
// Get absolute path

View File

@@ -31,6 +31,9 @@ func (suite *CoreTestSuite) SetupTest() {
err = os.Chdir(tempDir)
suite.Require().NoError(err)
// Set HOME to temp directory for consistent relative path calculation
suite.T().Setenv("HOME", tempDir)
// Set XDG_CONFIG_HOME to temp directory
suite.T().Setenv("XDG_CONFIG_HOME", tempDir)
@@ -86,8 +89,8 @@ func (suite *CoreTestSuite) TestCoreFileOperations() {
// The repository file will preserve the directory structure
lnkDir := filepath.Join(suite.tempDir, "lnk")
// Find the .bashrc file in the repository (it should be at the relative path)
repoFile := filepath.Join(lnkDir, suite.tempDir, ".bashrc")
// Find the .bashrc file in the repository (it should be at the relative path from HOME)
repoFile := filepath.Join(lnkDir, ".bashrc")
suite.FileExists(repoFile)
// Verify content is preserved
@@ -137,8 +140,8 @@ func (suite *CoreTestSuite) TestCoreDirectoryOperations() {
// Check that the repository directory preserves the structure
lnkDir := filepath.Join(suite.tempDir, "lnk")
// The directory should be at the relative path
repoDir := filepath.Join(lnkDir, suite.tempDir, "testdir")
// The directory should be at the relative path from HOME
repoDir := filepath.Join(lnkDir, "testdir")
suite.DirExists(repoDir)
// Remove the directory
@@ -820,6 +823,273 @@ func (suite *CoreTestSuite) TestRunBootstrapScriptNotFound() {
suite.Contains(err.Error(), "Bootstrap script not found")
}
func (suite *CoreTestSuite) TestAddMultiple() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create multiple test files
file1 := filepath.Join(suite.tempDir, "file1.txt")
file2 := filepath.Join(suite.tempDir, "file2.txt")
file3 := filepath.Join(suite.tempDir, "file3.txt")
content1 := "content1"
content2 := "content2"
content3 := "content3"
err = os.WriteFile(file1, []byte(content1), 0644)
suite.Require().NoError(err)
err = os.WriteFile(file2, []byte(content2), 0644)
suite.Require().NoError(err)
err = os.WriteFile(file3, []byte(content3), 0644)
suite.Require().NoError(err)
// Test AddMultiple method - should succeed
paths := []string{file1, file2, file3}
err = suite.lnk.AddMultiple(paths)
suite.NoError(err, "AddMultiple should succeed")
// Verify all files are now symlinks
for _, file := range paths {
info, err := os.Lstat(file)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "File should be a symlink: %s", file)
}
// Verify all files exist in storage
lnkDir := filepath.Join(suite.tempDir, "lnk")
suite.FileExists(filepath.Join(lnkDir, "file1.txt"))
suite.FileExists(filepath.Join(lnkDir, "file2.txt"))
suite.FileExists(filepath.Join(lnkDir, "file3.txt"))
// Verify .lnk file contains all entries
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal("file1.txt\nfile2.txt\nfile3.txt\n", string(lnkContent))
// Verify Git commit was created
commits, err := suite.lnk.GetCommits()
suite.NoError(err)
suite.T().Logf("Commits: %v", commits)
// Should have at least 1 commit for the batch add
suite.GreaterOrEqual(len(commits), 1)
// The most recent commit should mention multiple files
suite.Contains(commits[0], "added 3 files")
}
func (suite *CoreTestSuite) TestAddMultipleWithConflicts() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create test files
file1 := filepath.Join(suite.tempDir, "file1.txt")
file2 := filepath.Join(suite.tempDir, "file2.txt")
file3 := filepath.Join(suite.tempDir, "file3.txt")
err = os.WriteFile(file1, []byte("content1"), 0644)
suite.Require().NoError(err)
err = os.WriteFile(file2, []byte("content2"), 0644)
suite.Require().NoError(err)
err = os.WriteFile(file3, []byte("content3"), 0644)
suite.Require().NoError(err)
// Add file2 individually first
err = suite.lnk.Add(file2)
suite.Require().NoError(err)
// Now try to add all three - should fail due to conflict with file2
paths := []string{file1, file2, file3}
err = suite.lnk.AddMultiple(paths)
suite.Error(err, "AddMultiple should fail due to conflict")
suite.Contains(err.Error(), "already managed")
// Verify no partial changes were made
// file1 and file3 should still be regular files, not symlinks
info1, err := os.Lstat(file1)
suite.NoError(err)
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink, "file1 should not be a symlink")
info3, err := os.Lstat(file3)
suite.NoError(err)
suite.Equal(os.FileMode(0), info3.Mode()&os.ModeSymlink, "file3 should not be a symlink")
// file2 should still be managed (was added before)
info2, err := os.Lstat(file2)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink, "file2 should remain a symlink")
}
func (suite *CoreTestSuite) TestAddMultipleRollback() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create test files - one will be invalid to force rollback
file1 := filepath.Join(suite.tempDir, "file1.txt")
file2 := filepath.Join(suite.tempDir, "nonexistent.txt") // This doesn't exist
file3 := filepath.Join(suite.tempDir, "file3.txt")
err = os.WriteFile(file1, []byte("content1"), 0644)
suite.Require().NoError(err)
err = os.WriteFile(file3, []byte("content3"), 0644)
suite.Require().NoError(err)
// Note: file2 is intentionally not created
// Try to add all files - should fail and rollback
paths := []string{file1, file2, file3}
err = suite.lnk.AddMultiple(paths)
suite.Error(err, "AddMultiple should fail due to nonexistent file")
// Verify rollback - no files should be symlinks
info1, err := os.Lstat(file1)
suite.NoError(err)
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink, "file1 should not be a symlink after rollback")
info3, err := os.Lstat(file3)
suite.NoError(err)
suite.Equal(os.FileMode(0), info3.Mode()&os.ModeSymlink, "file3 should not be a symlink after rollback")
// Verify no files in storage
lnkDir := filepath.Join(suite.tempDir, "lnk")
suite.NoFileExists(filepath.Join(lnkDir, "file1.txt"))
suite.NoFileExists(filepath.Join(lnkDir, "file3.txt"))
// Verify .lnk file is empty or doesn't contain these files
lnkFile := filepath.Join(lnkDir, ".lnk")
if _, err := os.Stat(lnkFile); err == nil {
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
content := string(lnkContent)
suite.NotContains(content, "file1.txt")
suite.NotContains(content, "file3.txt")
}
}
func (suite *CoreTestSuite) TestValidateMultiplePaths() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create a mix of valid and invalid paths
validFile := filepath.Join(suite.tempDir, "valid.txt")
err = os.WriteFile(validFile, []byte("content"), 0644)
suite.Require().NoError(err)
nonexistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
// Don't create this file
// Create a valid directory
validDir := filepath.Join(suite.tempDir, "validdir")
err = os.MkdirAll(validDir, 0755)
suite.Require().NoError(err)
// Test validation fails early with detailed error
paths := []string{validFile, nonexistentFile, validDir}
err = suite.lnk.AddMultiple(paths)
suite.Error(err, "Should fail due to nonexistent file")
suite.Contains(err.Error(), "validation failed")
suite.Contains(err.Error(), "nonexistent.txt")
// Verify no partial changes were made
info, err := os.Lstat(validFile)
suite.NoError(err)
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "valid file should not be a symlink")
info, err = os.Lstat(validDir)
suite.NoError(err)
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "valid directory should not be a symlink")
}
func (suite *CoreTestSuite) TestAtomicRollbackOnFailure() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create test files
file1 := filepath.Join(suite.tempDir, "file1.txt")
file2 := filepath.Join(suite.tempDir, "file2.txt")
file3 := filepath.Join(suite.tempDir, "file3.txt")
content1 := "original content 1"
content2 := "original content 2"
content3 := "original content 3"
err = os.WriteFile(file1, []byte(content1), 0644)
suite.Require().NoError(err)
err = os.WriteFile(file2, []byte(content2), 0644)
suite.Require().NoError(err)
err = os.WriteFile(file3, []byte(content3), 0644)
suite.Require().NoError(err)
// Add file2 individually first to create a conflict
err = suite.lnk.Add(file2)
suite.Require().NoError(err)
// Store original states
info1Before, err := os.Lstat(file1)
suite.Require().NoError(err)
info3Before, err := os.Lstat(file3)
suite.Require().NoError(err)
// Try to add all files - should fail and rollback completely
paths := []string{file1, file2, file3}
err = suite.lnk.AddMultiple(paths)
suite.Error(err, "Should fail due to conflict with file2")
// Verify complete rollback
info1After, err := os.Lstat(file1)
suite.NoError(err)
suite.Equal(info1Before.Mode(), info1After.Mode(), "file1 mode should be unchanged")
info3After, err := os.Lstat(file3)
suite.NoError(err)
suite.Equal(info3Before.Mode(), info3After.Mode(), "file3 mode should be unchanged")
// Verify original contents are preserved
content1After, err := os.ReadFile(file1)
suite.NoError(err)
suite.Equal(content1, string(content1After), "file1 content should be preserved")
content3After, err := os.ReadFile(file3)
suite.NoError(err)
suite.Equal(content3, string(content3After), "file3 content should be preserved")
// file2 should still be managed (was added before)
info2, err := os.Lstat(file2)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink, "file2 should remain a symlink")
}
func (suite *CoreTestSuite) TestDetailedErrorMessages() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Test with multiple types of errors
validFile := filepath.Join(suite.tempDir, "valid.txt")
err = os.WriteFile(validFile, []byte("content"), 0644)
suite.Require().NoError(err)
nonexistentFile := filepath.Join(suite.tempDir, "does-not-exist.txt")
alreadyManagedFile := filepath.Join(suite.tempDir, "already-managed.txt")
err = os.WriteFile(alreadyManagedFile, []byte("managed"), 0644)
suite.Require().NoError(err)
// Add one file first to create conflict
err = suite.lnk.Add(alreadyManagedFile)
suite.Require().NoError(err)
// Test with nonexistent file
paths := []string{validFile, nonexistentFile}
err = suite.lnk.AddMultiple(paths)
suite.Error(err, "Should fail due to nonexistent file")
suite.Contains(err.Error(), "validation failed", "Error should mention validation failure")
suite.Contains(err.Error(), "does-not-exist.txt", "Error should include specific filename")
// Test with already managed file
paths = []string{validFile, alreadyManagedFile}
err = suite.lnk.AddMultiple(paths)
suite.Error(err, "Should fail due to already managed file")
suite.Contains(err.Error(), "already managed", "Error should mention already managed")
suite.Contains(err.Error(), "already-managed.txt", "Error should include specific filename")
}
func TestCoreSuite(t *testing.T) {
suite.Run(t, new(CoreTestSuite))
}