Skip to content

feat(sync): SFTP pull direction (remote to local) #413

@risu729

Description

@risu729

Summary

Add an explicit SFTP pull mode where the remote project directory is the source of truth and the local sync root is updated to match it.

Default biwa sync remains local -> remote push. Pull must be opt-in because it can overwrite or delete local files.

Current implementation check

  • src/cli/sync.rs exposes push-only sync flags: --sync-root, --sync-cwd, --remote-dir, --force, --exclude, and --include.
  • src/ssh/sync.rs currently collects local state with collect_local_state, fetches remote state with fetch_remote_state, calculates push actions with calculate_sync_actions, then uploads via SFTP.
  • Push mode already deletes remote extras, tracks empty directories, skips symlinks, respects .gitignore / .biwaignore, and uses SHA-256 hash comparison unless --force is set.
  • There is no download path, local deletion path, or sync direction enum today.
  • sync.engine = "mutagen" is recognized in config but rejected by sync_project; this issue is SFTP-only.

Proposed CLI

# Default behavior remains push: local -> remote
biwa sync

# Pull remote project contents into the default local sync root
biwa sync --pull

# Pull a specific remote directory into the current project root
biwa sync --pull --remote-dir '~/course-work/lab01'

# Pull into an explicit local root
biwa sync --pull --sync-root ./lab01 --remote-dir '~/course-work/lab01'

# Pull only selected files and overwrite even when hashes match
biwa sync --pull --include 'src/**' --include 'Cargo.toml' --force

Add a warning to --pull help that local files and directories missing from the remote source can be deleted.

Behavior

  • Push remains the default and keeps its existing semantics.
  • Pull treats the remote directory as source of truth for that run.
  • Files are downloaded when the remote hash differs from the local hash, when the local file is missing, or when --force is set.
  • Local files/directories that are within the selected sync scope but absent from the remote state are deleted.
  • Empty remote directories are created locally.
  • Remote symlinks should not be followed. Decide whether to skip them with a warning or represent them in state and reject the pull; do not write through symlinks locally.
  • Local deletes must refuse paths outside sync_root, absolute remote paths, .. traversal, and symlink traversal.
  • Include/exclude behavior should mirror push as closely as possible. Document any asymmetry caused by remote-side filtering.

Implementation plan

  1. Add a sync direction type, for example SyncDirection::{Push, Pull}, plus a --pull flag on SyncArgs.
  2. Keep SyncArgs::resolve_options direction-neutral. Pass direction into the sync entrypoint rather than overloading Options if that keeps the API clearer.
  3. Extend remote state collection so pull can identify remote files, directories, and possibly remote symlinks without relying on unsafe path strings.
  4. Add a local state shape usable for pull deletion decisions. It can reuse collect_local_state, but review how include/exclude patterns are absolutized today before applying them to local deletions.
  5. Implement calculate_pull_actions separately from calculate_sync_actions: downloads, local file deletions, local directory creations, and local directory deletions.
  6. Implement download_file using the existing SFTP session: open remote file read-only, create parent directories locally, write to a temporary local file, then atomically rename into place.
  7. Preserve local safety: reject writes through local symlinks, reject paths escaping the sync root, and delete deepest-first.
  8. Add stats fields or direction-specific stats, for example downloaded, deleted, and unchanged, without making push output confusing.
  9. Update biwa run behavior deliberately. If SyncArgs is shared, either support biwa run --pull --sync ... with documented semantics or reject --pull on run with a clear error until there is a concrete use case.
  10. Run mise run render:usage after CLI changes.

Checks

  • Unit tests for pull action calculation: changed file, missing local file, deleted remote file, empty directory creation/removal, file-vs-directory conflicts, --force, include/exclude, and deterministic ordering.
  • Unit tests for path safety: .., absolute paths, remote symlink entries, local symlink files/directories, and nested deletion ordering.
  • E2E tests against the SSH container: remote edit then biwa sync --pull, remote delete then local deletion, remote empty directory, --remote-dir, --sync-root, --include, and --exclude.
  • Snapshot or CLI parse tests for --pull help/usage.
  • Run at minimum: cargo test --test ssh_e2e_sync, cargo test sync, mise run render:usage, and LINT=true mise run check.

Docs updates

  • Update docs/src/sync-behavior.md with push vs pull direction, destructive deletion semantics, and safety notes.
  • Update generated CLI docs via mise run render:usage.
  • Add examples to Getting Started or Sync Behavior showing a remote edit workflow and a safe dry-run alternative if one is added later.
  • Update configuration docs only if a persistent sync.direction setting is added; otherwise keep direction CLI-only.

Acceptance criteria

  • biwa sync remains push-only by default.
  • biwa sync --pull downloads remote changes and deletes local extras inside the sync root only.
  • Pull never writes outside the local sync root and never follows local or remote symlinks unsafely.
  • Pull has focused unit coverage and SSH e2e coverage.
  • CLI help and docs clearly describe destructive local deletion behavior.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions