Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ Synced verbatim, those would be **different projects** and `claude --resume` on

Sessions under either directory sync to the shared `${WORK}` namespace and resume correctly on both machines.

- **Symlinked project roots are canonicalized.** If a mapped directory is a symlink (e.g. `~/Developer` pointing at `~/Documents/Projects`), the real target is used, so sessions recorded through the symlink resume under the path `claude --resume` looks them up by. To keep the literal symlink path instead, set `resolve_symlinks: false` in `~/.claude-sync/config.yaml`.

**Upgrading from an older version?** Run `claude-sync migrate` once on each device to convert existing remote data to portable keys. Paths the current device doesn't own are left for the other device's migrate run.

## Commands
Expand Down
18 changes: 6 additions & 12 deletions cmd/claude-sync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1501,18 +1501,12 @@ func findConflicts(claudeDir string) ([]conflictFile, error) {
return nil
}

// Look for .conflict. pattern
if strings.Contains(filepath.Base(path), ".conflict.") {
// Extract original path and timestamp
// Format: filename.ext.conflict.20260208-095132
parts := strings.Split(path, ".conflict.")
if len(parts) == 2 {
conflicts = append(conflicts, conflictFile{
ConflictPath: path,
OriginalPath: parts[0],
Timestamp: parts[1],
})
}
if original, timestamp, ok := sync.SplitConflictPath(path); ok {
conflicts = append(conflicts, conflictFile{
ConflictPath: path,
OriginalPath: original,
Timestamp: timestamp,
})
}
return nil
})
Expand Down
12 changes: 12 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ type Config struct {
// ~/Projects: WORK
PathMap map[string]string `yaml:"path_map,omitempty"`

// ResolveSymlinks canonicalizes a mapped prefix that is a symlink to its
// target, so sessions recorded through the symlink resume under the real
// path Claude Code looks them up by. Nil defaults to enabled; set false to
// keep the literal symlink path. See ResolveSymlinksEnabled.
ResolveSymlinks *bool `yaml:"resolve_symlinks,omitempty"`

// ClaudeDirOverride allows overriding the default ~/.claude path (for testing)
ClaudeDirOverride string `yaml:"-"`

Expand Down Expand Up @@ -317,6 +323,12 @@ func (c *Config) SetMCPSync(enabled bool) {
c.MCPSync = &enabled
}

// ResolveSymlinksEnabled reports whether symlinked prefixes are canonicalized.
// Nil (unset) defaults to true; only an explicit false disables it.
func (c *Config) ResolveSymlinksEnabled() bool {
return c.ResolveSymlinks == nil || *c.ResolveSymlinks
}

// IsExcluded returns true if the given relative path matches any exclude pattern.
// Patterns support:
// - Full doublestar glob syntax including ** for recursive matching
Expand Down
21 changes: 21 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,27 @@ func TestIsMCPSyncEnabled(t *testing.T) {
}
}

func TestResolveSymlinksEnabled(t *testing.T) {
tests := []struct {
name string
value *bool
want bool
}{
{"nil (unset) defaults to true", nil, true},
{"true returns true", boolPtr(true), true},
{"false returns false", boolPtr(false), false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{ResolveSymlinks: tt.value}
if got := cfg.ResolveSymlinksEnabled(); got != tt.want {
t.Errorf("ResolveSymlinksEnabled() = %v, want %v", got, tt.want)
}
})
}
}

func TestSetMCPSync(t *testing.T) {
cfg := &Config{}

Expand Down
Loading