Skip to content
16 changes: 16 additions & 0 deletions cmd/claude-sync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,18 @@ func runWebDAVWizard(webdavURL, username, password, pathPrefix string) (*storage
return cfg, nil
}

// reportQuietErrors prints per-file sync errors to stderr in quiet mode.
// Session hooks run with -q, which suppresses all normal output — but a
// failure must never be invisible.
func reportQuietErrors(result *sync.SyncResult) {
if !quiet || result == nil {
return
}
for _, e := range result.Errors {
fmt.Fprintf(os.Stderr, "claude-sync: %v\n", e)
}
}

func pushCmd() *cobra.Command {
var includeMCP bool

Expand Down Expand Up @@ -1072,6 +1084,8 @@ func pushCmd() *cobra.Command {
return err
}

reportQuietErrors(result)

if !quiet {
fmt.Println() // Clear the progress line

Expand Down Expand Up @@ -1201,6 +1215,8 @@ Examples:
return err
}

reportQuietErrors(result)

if !quiet {
fmt.Println() // Clear the progress line

Expand Down
3 changes: 3 additions & 0 deletions internal/storage/gcs/gcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ func (c *Client) List(ctx context.Context, prefix string) ([]appstorage.ObjectIn
func (c *Client) Head(ctx context.Context, key string) (*appstorage.ObjectInfo, error) {
attrs, err := c.client.Bucket(c.bucket).Object(key).Attrs(ctx)
if err != nil {
if errors.Is(err, storage.ErrObjectNotExist) {
return nil, fmt.Errorf("%s: %w", key, appstorage.ErrNotFound)
}
return nil, err
}

Expand Down
4 changes: 4 additions & 0 deletions internal/storage/r2/r2.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ func (c *Client) Head(ctx context.Context, key string) (*storage.ObjectInfo, err
Key: aws.String(key),
})
if err != nil {
var notFound *types.NotFound
if errors.As(err, &notFound) {
return nil, fmt.Errorf("%s: %w", key, storage.ErrNotFound)
}
return nil, err
}

Expand Down
4 changes: 4 additions & 0 deletions internal/storage/s3/s3.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ func (c *Client) Head(ctx context.Context, key string) (*storage.ObjectInfo, err
Key: aws.String(key),
})
if err != nil {
var notFound *types.NotFound
if errors.As(err, &notFound) {
return nil, fmt.Errorf("%s: %w", key, storage.ErrNotFound)
}
return nil, err
}

Expand Down
13 changes: 13 additions & 0 deletions internal/storage/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@ package storage

import (
"context"
"errors"
"fmt"
"time"
)

// ErrNotFound distinguishes a missing object from transient failures
// (timeouts, throttling, 5xx). Adapters wrap their provider-specific
// not-found errors with it; callers that must not confuse "absent" with
// "unreachable" — such as the history merge-on-push, where the difference is
// clobbering the bucket union — check it via IsNotFound.
var ErrNotFound = errors.New("object not found")

// IsNotFound reports whether err indicates the object does not exist.
func IsNotFound(err error) bool {
return errors.Is(err, ErrNotFound)
}

// Provider represents a storage provider type
type Provider string

Expand Down
2 changes: 1 addition & 1 deletion internal/storage/webdav/webdav.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ func (c *Client) Head(ctx context.Context, key string) (*storage.ObjectInfo, err
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("object not found: %s", key)
return nil, fmt.Errorf("%s: %w", key, storage.ErrNotFound)
}

if resp.StatusCode != 207 {
Expand Down
Loading