Skip to content

Commit 6e86471

Browse files
committed
shell: Add sync-exclude flag and .limasyncignore file
To avoid syncing generated or sensitive data into a vm, a new sync-exclude flag is added, to be used in conjunction with --sync. This allows to specify rsync excludes which are then not synced. Additionally a .limasyncignore file can be created, which also considered and passed to rsync as the exclude-from flag. Signed-off-by: Mathias Petermann <mathias.petermann@gmail.com>
1 parent 898ac65 commit 6e86471

3 files changed

Lines changed: 141 additions & 8 deletions

File tree

cmd/limactl/shell.go

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ func newShellCommand() *cobra.Command {
7373
shellCmd.Flags().Bool("preserve-env", false, "Propagate environment variables to the shell")
7474
shellCmd.Flags().Bool("start", false, "Start the instance if it is not already running")
7575
shellCmd.Flags().String("sync", "", "Copy a host directory to the guest and vice-versa upon exit")
76+
shellCmd.Flags().StringArray("sync-exclude", nil, "Exclude pattern for --sync (can be specified multiple times)")
7677

7778
return shellCmd
7879
}
@@ -196,10 +197,22 @@ func shellAction(cmd *cobra.Command, args []string) error {
196197
return fmt.Errorf("failed to get sync flag: %w", err)
197198
}
198199
syncHostWorkdir := syncDirVal != ""
200+
// Validate that --sync has a proper directory value, not another flag
201+
if err := validateSyncFlagValue(syncDirVal); err != nil {
202+
return err
203+
}
199204
if syncHostWorkdir && len(inst.Config.Mounts) > 0 {
200205
return errors.New("cannot use `--sync` when the instance has host mounts configured, start the instance with `--mount-none` to disable mounts")
201206
}
202207

208+
syncExcludes, err := flags.GetStringArray("sync-exclude")
209+
if err != nil {
210+
return fmt.Errorf("failed to get sync-exclude flag: %w", err)
211+
}
212+
if len(syncExcludes) > 0 && !syncHostWorkdir {
213+
return errors.New("cannot use `--sync-exclude` without `--sync`")
214+
}
215+
203216
// When workDir is explicitly set, the shell MUST have workDir as the cwd, or exit with an error.
204217
//
205218
// changeDirCmd := "cd workDir || exit 1" if workDir != ""
@@ -354,6 +367,7 @@ func shellAction(cmd *cobra.Command, args []string) error {
354367
var (
355368
sshExecForRsync *exec.Cmd
356369
rsync copytool.CopyTool
370+
excludeArgs []string
357371
)
358372
if syncHostWorkdir {
359373
logrus.Infof("Syncing host current directory(%s) to guest instance...", hostCurrentDir)
@@ -380,12 +394,13 @@ func shellAction(cmd *cobra.Command, args []string) error {
380394
hostCurrentDir,
381395
fmt.Sprintf("%s:%s", inst.Name, destRsyncDir),
382396
}
397+
excludeArgs = buildSyncExcludeArgs(syncExcludes, hostCurrentDir)
383398
rsync, err = copytool.New(ctx, string(copytool.BackendRsync), paths, &copytool.Options{
384399
Recursive: true,
385400
Verbose: false,
386-
AdditionalArgs: []string{
401+
AdditionalArgs: append([]string{
387402
"--delete",
388-
},
403+
}, excludeArgs...),
389404
})
390405
if err != nil {
391406
return err
@@ -439,12 +454,12 @@ func shellAction(cmd *cobra.Command, args []string) error {
439454
if err != nil {
440455
return err
441456
}
442-
return askUserForRsyncBack(ctx, cmd, inst, sshExecForRsync, hostCurrentDir, destRsyncDir, rsync, tty)
457+
return askUserForRsyncBack(ctx, cmd, inst, sshExecForRsync, hostCurrentDir, destRsyncDir, rsync, tty, excludeArgs)
443458
}
444459
return nil
445460
}
446461

447-
func askUserForRsyncBack(ctx context.Context, cmd *cobra.Command, inst *limatype.Instance, sshCmd *exec.Cmd, hostCurrentDir, destRsyncDir string, rsync copytool.CopyTool, tty bool) error {
462+
func askUserForRsyncBack(ctx context.Context, cmd *cobra.Command, inst *limatype.Instance, sshCmd *exec.Cmd, hostCurrentDir, destRsyncDir string, rsync copytool.CopyTool, tty bool, excludeArgs []string) error {
448463
remoteSource := fmt.Sprintf("%s:%s", inst.Name, destRsyncDir)
449464
clean := filepath.Clean(hostCurrentDir)
450465
dirForCleanup := shellescape.Quote(filepath.Join(*inst.Config.User.Home, clean))
@@ -473,7 +488,7 @@ func askUserForRsyncBack(ctx context.Context, cmd *cobra.Command, inst *limatype
473488
return rsyncBack()
474489
}
475490

476-
rawOutput, stats, err := getRsyncStats(ctx, remoteSource, filepath.Dir(hostCurrentDir))
491+
rawOutput, stats, err := getRsyncStats(ctx, remoteSource, filepath.Dir(hostCurrentDir), excludeArgs)
477492
if err != nil {
478493
logrus.WithError(err).Warn("failed to get rsync stats")
479494
}
@@ -742,16 +757,16 @@ func (s *rsyncStats) String() string {
742757
return fmt.Sprintf("added: %d, deleted: %d, modified: %d, metadata: %d", s.Added, s.Deleted, s.Modified, s.Metadata)
743758
}
744759

745-
func getRsyncStats(ctx context.Context, source, destination string) (string, *rsyncStats, error) {
760+
func getRsyncStats(ctx context.Context, source, destination string, excludeArgs []string) (string, *rsyncStats, error) {
746761
paths := []string{source, destination}
747762
rsync, err := copytool.New(ctx, string(copytool.BackendRsync), paths, &copytool.Options{
748763
Verbose: true,
749-
AdditionalArgs: []string{
764+
AdditionalArgs: append([]string{
750765
"--dry-run",
751766
"--itemize-changes",
752767
"-ah",
753768
"--delete",
754-
},
769+
}, excludeArgs...),
755770
})
756771
if err != nil {
757772
return "", nil, err
@@ -822,6 +837,29 @@ func parseRsyncStats(output string) *rsyncStats {
822837
return &s
823838
}
824839

840+
// buildSyncExcludeArgs converts --sync-exclude flag values and an optional
841+
// .limasyncignore file into rsync --exclude / --exclude-from arguments.
842+
func buildSyncExcludeArgs(excludes []string, syncDir string) []string {
843+
var args []string
844+
for _, pattern := range excludes {
845+
args = append(args, "--exclude", pattern)
846+
}
847+
ignoreFile := filepath.Join(syncDir, ".limasyncignore")
848+
if _, err := os.Stat(ignoreFile); err == nil {
849+
args = append(args, "--exclude-from", ignoreFile)
850+
}
851+
return args
852+
}
853+
854+
// validateSyncFlagValue ensures the value passed to --sync is a directory
855+
// path and not another flag token like "--sync-exclude=...".
856+
func validateSyncFlagValue(syncDirVal string) error {
857+
if syncDirVal != "" && strings.HasPrefix(syncDirVal, "--") {
858+
return fmt.Errorf("--sync flag requires a directory path argument, got %q instead\nUsage: limactl shell --sync <DIR> [--sync-exclude <PATTERN>...] <INSTANCE> [COMMAND...]", syncDirVal)
859+
}
860+
return nil
861+
}
862+
825863
func hasMetadataDelta(attrs string) bool {
826864
for _, ch := range attrs {
827865
if ch != '.' {

cmd/limactl/shell_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,69 @@
44
package main
55

66
import (
7+
"os"
8+
"path/filepath"
79
"testing"
810

911
"gotest.tools/v3/assert"
1012
)
1113

14+
func TestBuildSyncExcludeArgs(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
excludes []string
18+
hasIgnore bool
19+
expected []string
20+
}{
21+
{
22+
name: "empty excludes, no ignore file",
23+
excludes: nil,
24+
expected: nil,
25+
},
26+
{
27+
name: "single exclude",
28+
excludes: []string{"node_modules"},
29+
expected: []string{"--exclude", "node_modules"},
30+
},
31+
{
32+
name: "multiple excludes",
33+
excludes: []string{"node_modules", ".git", "vendor"},
34+
expected: []string{"--exclude", "node_modules", "--exclude", ".git", "--exclude", "vendor"},
35+
},
36+
{
37+
name: "ignore file only",
38+
excludes: nil,
39+
hasIgnore: true,
40+
expected: []string{"--exclude-from", ""}, // placeholder, patched below
41+
},
42+
{
43+
name: "excludes and ignore file",
44+
excludes: []string{".git"},
45+
hasIgnore: true,
46+
expected: []string{"--exclude", ".git", "--exclude-from", ""}, // placeholder
47+
},
48+
}
49+
50+
for _, tt := range tests {
51+
t.Run(tt.name, func(t *testing.T) {
52+
dir := t.TempDir()
53+
if tt.hasIgnore {
54+
ignoreFile := filepath.Join(dir, ".limasyncignore")
55+
err := os.WriteFile(ignoreFile, []byte("build\ndist\n"), 0o644)
56+
assert.NilError(t, err)
57+
// Patch expected with actual path
58+
for i, v := range tt.expected {
59+
if v == "" {
60+
tt.expected[i] = ignoreFile
61+
}
62+
}
63+
}
64+
got := buildSyncExcludeArgs(tt.excludes, dir)
65+
assert.DeepEqual(t, got, tt.expected)
66+
})
67+
}
68+
}
69+
1270
func TestParseRsyncStats(t *testing.T) {
1371
tests := []struct {
1472
name string
@@ -68,6 +126,16 @@ cL+++++++++ new-symlink -> target
68126
}
69127
}
70128

129+
func TestValidateSyncFlagValue(t *testing.T) {
130+
// When the sync value looks like another flag, validation should return an error
131+
err := validateSyncFlagValue("--sync-exclude=.git")
132+
assert.ErrorContains(t, err, "--sync flag requires a directory path")
133+
134+
// When the sync value is a normal directory, validation should pass
135+
err = validateSyncFlagValue(".")
136+
assert.NilError(t, err)
137+
}
138+
71139
func TestRsyncStatsString(t *testing.T) {
72140
tests := []struct {
73141
name string

website/content/en/docs/examples/ai.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,33 @@ limactl shell --sync . default
185185
- **No**: Discards changes and cleans up guest directory
186186
- **View the changed contents**: Shows a diff of changes made by the agent
187187

188+
### Excluding files or directories from syncing
189+
190+
| ⚡ Requirement | Lima >= 2.2 |
191+
|----------------|-------------|
192+
193+
In addition to the `--sync` flag, a `--sync-exclude` flag is available, which allows you to configure directories that will not be synced to the VM. This is useful to exclude large generated directories (like `node_modules`, or `vendor`),
194+
or more sensitive files (like credentials, or a `.git` directory). This flag can be repeated to exclude multiple directories.
195+
196+
The flag is passed to **rsync** as `--exclude` and must conform to its conventions.
197+
198+
```bash
199+
limactl shell --sync . --sync-exclude .git default
200+
```
201+
202+
Alternatively a `.limasyncignore` file can be created in the target directory, which contains a list of excludes.
203+
204+
```
205+
node_modules
206+
.git
207+
vendor
208+
build
209+
dist
210+
*.o
211+
```
212+
213+
If this file exists, it is automatically passed to **rsync** as `--exclude-from`.
214+
188215
### Requirements
189216

190217
- **rsync** must be installed on both host and guest

0 commit comments

Comments
 (0)