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
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
198 changes: 190 additions & 8 deletions internal/sync/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package sync

import (
"bytes"
"encoding/json"
"fmt"
"path"
"regexp"
Expand Down Expand Up @@ -38,6 +39,10 @@ type pathMapping struct {
encLocal string // localPath in Claude Code's directory encoding
normRe *regexp.Regexp
normRepl []byte // replacement template: token ($-escaped) + boundary group
// resolveRe captures the token and its path tail so the tail's separators
// can follow localPath's convention; otherwise a foreign-OS separator
// survives after the prefix (e.g. C:\Users\bob/foo) and cwd matching fails.
resolveRe *regexp.Regexp
}

var pathTokenNameRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
Expand Down Expand Up @@ -65,7 +70,8 @@ func NewPathMapper(homeDir string, userMap map[string]string) (*PathMapper, erro
normRe: re,
// "$$" = literal "$" in a regexp replacement template; without it
// "${HOME}" would itself be read as a group reference
normRepl: []byte("$${" + name + "}${1}"),
normRepl: []byte("$${" + name + "}${1}"),
resolveRe: regexp.MustCompile(regexp.QuoteMeta(pathToken(name)) + `([/\\][^"\s]*)?`),
})
return nil
}
Expand Down Expand Up @@ -182,25 +188,60 @@ func (m *PathMapper) NormalizeContent(data []byte) []byte {
return data
}

// ResolveContent replaces portable tokens with this device's local paths.
// ResolveContent replaces portable tokens with this device's local paths,
// rewriting the following path tail's separators to localPath's convention so a
// path authored on another OS resolves to a valid native path.
func (m *PathMapper) ResolveContent(data []byte) []byte {
if m == nil {
return data
}
for _, mp := range m.mappings {
data = bytes.ReplaceAll(data, []byte(pathToken(mp.name)), []byte(mp.localPath))
sep := pathSep(mp.localPath)
data = mp.resolveRe.ReplaceAllFunc(data, func(match []byte) []byte {
tail := match[len(pathToken(mp.name)):]
return append([]byte(mp.localPath), replaceSeps(string(tail), sep)...)
})
}
return data
}

// portableStatePaths are files outside projects/ that embed absolute paths.
// A JSON file is translated by decoding it and rewriting string values: a
// Windows path is escaped in the source ("C:\\Users\\bob"), so raw byte
// replacement would eat an escape and leave the document invalid.
var portableStatePaths = map[string]bool{
"history.jsonl": true,
"plugins/known_marketplaces.json": true,
"plugins/installed_plugins.json": true,
}

// basePath is the path a translation rule applies to: a conflict copy follows
// the file it was made from, so its content is resolved for this device and
// stays comparable against the local version.
func basePath(relPath string) string {
base, _, _ := SplitConflictPath(relPath)
return path.Clean(base)
}

// isPortableJSONPath reports whether relPath is translated as a single JSON
// document rather than raw bytes.
func isPortableJSONPath(relPath string) bool {
return path.Ext(relPath) == ".json"
}

// isPortableJSONLPath reports whether relPath is JSON-lines, translated one
// JSON document per line.
func isPortableJSONLPath(relPath string) bool {
return path.Ext(relPath) == ".jsonl"
}

// IsPortableContentPath reports whether content path translation applies to
// this relative path: text formats under projects/ plus the prompt history.
// this relative path: text formats under projects/, the prompt history, and
// the plugin state files.
// Conflict copies (path.conflict.<timestamp>) inherit the base path's rule.
func IsPortableContentPath(relPath string) bool {
if i := strings.Index(relPath, ".conflict."); i >= 0 {
relPath = relPath[:i]
}
if relPath == "history.jsonl" {
relPath = basePath(relPath)
if portableStatePaths[relPath] {
return true
}
if !strings.HasPrefix(relPath, "projects/") {
Expand All @@ -212,3 +253,144 @@ func IsPortableContentPath(relPath string) bool {
}
return false
}

// NormalizeFile replaces this device's mapped path prefixes with portable
// tokens in the content of relPath. JSON and JSON-lines files are translated
// through a JSON decode/encode so an inserted path stays correctly escaped and
// re-separated; other text is translated with boundary-aware byte replacement.
func (m *PathMapper) NormalizeFile(relPath string, data []byte) []byte {
return m.mapFile(relPath, data, false)
}

// ResolveFile replaces portable tokens with this device's local paths in the
// content of relPath, mirroring NormalizeFile.
func (m *PathMapper) ResolveFile(relPath string, data []byte) []byte {
return m.mapFile(relPath, data, true)
}

func (m *PathMapper) mapFile(relPath string, data []byte, resolve bool) []byte {
if m == nil {
return data
}
base := basePath(relPath)
switch {
case isPortableJSONPath(base):
return m.mapJSON(data, resolve, true)
case isPortableJSONLPath(base):
return m.mapJSONL(data, resolve)
case resolve:
return m.ResolveContent(data)
default:
return m.NormalizeContent(data)
}
}

// mapJSON translates every mapping in a single JSON document. resolve picks the
// direction: false normalizes local paths to tokens, true resolves tokens back.
func (m *PathMapper) mapJSON(data []byte, resolve, indent bool) []byte {
for _, mp := range m.mappings {
from, to := mp.localPath, pathToken(mp.name)
if resolve {
from, to = to, from
}
data = mapJSONPaths(data, from, to, indent)
}
return data
}

// mapJSONL translates a JSON-lines file one document per line, preserving line
// endings. Lines that are not valid JSON pass through unchanged.
func (m *PathMapper) mapJSONL(data []byte, resolve bool) []byte {
lines := bytes.Split(data, []byte("\n"))
for i, line := range lines {
trimmed := bytes.TrimRight(line, "\r")
if len(bytes.TrimSpace(trimmed)) == 0 {
continue
}
mapped := m.mapJSON(trimmed, resolve, false)
mapped = append(mapped, line[len(trimmed):]...)
lines[i] = mapped
}
return bytes.Join(lines, []byte("\n"))
}

// pathSep reports the separator p is written with, so a translated path keeps
// the target's convention rather than the running platform's.
func pathSep(p string) byte {
if strings.Contains(p, `\`) {
return '\\'
}
return '/'
}

// replaceSeps rewrites every separator in p to sep.
func replaceSeps(p string, sep byte) string {
return strings.Map(func(r rune) rune {
if r == '/' || r == '\\' {
return rune(sep)
}
return r
}, p)
}

// mapJSONPaths decodes data as JSON and rewrites every string value that
// begins with from, swapping that prefix for to and re-separating the
// remainder in to's convention. Escaping is left to the encoder, so a Windows
// path stays valid; content that is not valid JSON is returned unchanged.
// Indented output re-indents state files for readability; JSON-lines callers
// pass indent=false to keep each document on a single line.
func mapJSONPaths(data []byte, from, to string, indent bool) []byte {
var doc any
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
if err := dec.Decode(&doc); err != nil {
return data
}

mapPath := func(s string) string {
if !strings.HasPrefix(s, from) {
return s
}
rest := s[len(from):]
if rest != "" && rest[0] != '/' && rest[0] != '\\' {
return s
}
return to + replaceSeps(rest, pathSep(to))
}

var walk func(any) any
walk = func(v any) any {
switch t := v.(type) {
case string:
return mapPath(t)
case []any:
for i, e := range t {
t[i] = walk(e)
}
return t
case map[string]any:
for k, e := range t {
t[k] = walk(e)
}
return t
}
return v
}

var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if indent {
enc.SetIndent("", " ")
}
if err := enc.Encode(walk(doc)); err != nil {
return data
}
out := buf.Bytes()
if !indent {
// A JSON-lines document is one line; Encode always appends a newline the
// caller rejoins itself. State files keep the newline they had.
out = bytes.TrimSuffix(out, []byte("\n"))
}
return out
}
Loading