Skip to content
Closed
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
86 changes: 86 additions & 0 deletions internal/sync/debris_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
18 changes: 18 additions & 0 deletions internal/sync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
Expand All @@ -26,6 +27,14 @@ import (

const defaultWorkers = 10

// conflictArtifactRe matches only this tool's own conflict artifacts
// (<name>.conflict.<yyyymmdd>-<hhmmss>, 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
Expand Down Expand Up @@ -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.<ts> 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)
}

Expand Down