From bf14dad33d246124ae1bdb19e119b781226c1e8d Mon Sep 17 00:00:00 2001 From: Salvador Jalife Lozano Date: Sun, 2 Aug 2026 01:45:03 -0600 Subject: [PATCH] fix(sync): never sync per-machine debris (.lock and .conflict artifacts) Two classes of local-only file are currently uploaded and replicated to every machine. .lock: Claude Code writes a zero-byte lock per task directory (tasks//.lock). A lock represents a process holding a resource on one machine; replicated elsewhere it is indistinguishable from a lock genuinely held there, and it long outlives the process that made it. Observed: 12 of them, the oldest about four weeks old, syncing across three machines. .conflict.: handleConflict writes the remote copy next to the original, which is inside a synced directory, so the recovery artifact is itself uploaded. Each replica can then be re-detected on another machine and spawn further artifacts. Observed: 92 accumulated in about a week, including five copies of one snapshot minted by successive pulls. Both are now excluded unconditionally, ahead of the user's exclude patterns, because neither has any meaning on another machine under any configuration. The .conflict pattern is anchored on the exact - format this tool generates rather than matching .conflict. loosely. That matters: an already-tracked file becoming excluded is reported as a deletion and pruned from the bucket on the next push, so a loose pattern would silently delete a user file named e.g. notes.conflict.md from remote storage. Tests pin both directions. Migration note for existing users: the first push after upgrading prunes any already-uploaded locks and conflict artifacts from the bucket. Local copies are untouched. Fixes #71 Co-Authored-By: Claude Fable 5 --- internal/sync/debris_test.go | 86 ++++++++++++++++++++++++++++++++++++ internal/sync/sync.go | 18 ++++++++ 2 files changed, 104 insertions(+) create mode 100644 internal/sync/debris_test.go diff --git a/internal/sync/debris_test.go b/internal/sync/debris_test.go new file mode 100644 index 0000000..77d6d1f --- /dev/null +++ b/internal/sync/debris_test.go @@ -0,0 +1,86 @@ +package sync + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/tawanorg/claude-sync/internal/config" +) + +func TestIsExcludedSkipsPerMachineDebris(t *testing.T) { + s := &Syncer{cfg: &config.Config{}} + + excluded := []string{ + "tasks/6bf39315-3333-4394-86bb-2d57f8759b5a/.lock", + ".lock", + "projects/p1/sess.jsonl.conflict.20260729-145427", + "tasks/x/3.json.conflict.20260101-000000", + } + for _, p := range excluded { + if !s.isExcluded(p) { + t.Errorf("expected %q to be excluded as per-machine debris", p) + } + } + + // Real data must never be caught. The .conflict rule in particular is + // anchored on the exact timestamp format this tool writes, because + // excluding an already-tracked file prunes it from the bucket on the + // next push — a loose pattern would delete user data remotely. + kept := []string{ + "projects/p1/sess.jsonl", + "tasks/x/3.json", + "CLAUDE.md", + "projects/notes.conflict.md", // ".conflict." but not our artifact + "projects/p1/my.conflict.2026.txt", // wrong timestamp shape + "projects/p1/sess.jsonl.conflict.2026072", // truncated timestamp + "projects/lockfiles/package.lock", // ends in .lock but is not a bare .lock + "projects/p1/.lockfile", + } + for _, p := range kept { + if s.isExcluded(p) { + t.Errorf("expected %q NOT to be excluded", p) + } + } +} + +func TestIsExcludedStillHonorsUserPatterns(t *testing.T) { + s := &Syncer{cfg: &config.Config{Exclude: []string{"**/*.tmp"}}} + if !s.isExcluded("projects/p1/scratch.tmp") { + t.Error("user exclude patterns must still apply") + } + if s.isExcluded("projects/p1/keep.txt") { + t.Error("unrelated file must not be excluded") + } +} + +func TestPushSkipsDebrisFiles(t *testing.T) { + env := setupTestEnv(t) + ctx := context.Background() + + writeFile(t, env.claudeDir, "projects/p1/sess.jsonl", "{\"uuid\":\"u1\"}\n") + writeFile(t, env.claudeDir, "tasks/t1/.lock", "") + writeFile(t, env.claudeDir, "projects/p1/sess.jsonl.conflict.20260729-145427", "stale remote copy\n") + + result, err := env.syncer.Push(ctx) + if err != nil { + t.Fatalf("Push failed: %v", err) + } + if len(result.Uploaded) != 1 || result.Uploaded[0] != "projects/p1/sess.jsonl" { + t.Errorf("only the transcript should upload, got %v", result.Uploaded) + } + + objs, err := env.store.ListUserObjects(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(objs) != 1 { + t.Errorf("expected 1 remote object, got %d: %v", len(objs), objs) + } + + // The debris stays on disk; it is simply never published. + if _, err := os.Stat(filepath.Join(env.claudeDir, "tasks", "t1", ".lock")); err != nil { + t.Errorf("local .lock must not be removed: %v", err) + } +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 0a45bec..7169cdd 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "regexp" "strings" "sync" "sync/atomic" @@ -26,6 +27,14 @@ import ( const defaultWorkers = 10 +// conflictArtifactRe matches only this tool's own conflict artifacts +// (.conflict.-, the format handleConflict writes), +// never user files that merely contain ".conflict." in their names. The +// anchoring matters: excluding a path that is already tracked in state makes +// the next push prune it from the bucket, so a loose pattern would turn into +// remote deletion of real data. +var conflictArtifactRe = regexp.MustCompile(`\.conflict\.\d{8}-\d{6}$`) + // maxDecompressedSize is the maximum allowed size for decompressed data (500MB). // This prevents decompression bomb attacks from consuming excessive memory. const maxDecompressedSize = 500 * 1024 * 1024 @@ -146,6 +155,15 @@ func (s *Syncer) progress(event ProgressEvent) { } func (s *Syncer) isExcluded(relPath string) bool { + // Per-machine debris never syncs, regardless of user excludes. A .lock is + // a local process lock — on another machine it is indistinguishable from a + // lock genuinely held there. A .conflict. file is this tool's own local + // recovery artifact; uploading it replicates the artifact to every machine, + // where each copy can itself be re-detected and spawn further ones. + base := filepath.Base(relPath) + if base == ".lock" || conflictArtifactRe.MatchString(base) { + return true + } return s.cfg.IsExcluded(relPath) }