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
- Add a sync direction type, for example
SyncDirection::{Push, Pull}, plus a --pull flag on SyncArgs.
- Keep
SyncArgs::resolve_options direction-neutral. Pass direction into the sync entrypoint rather than overloading Options if that keeps the API clearer.
- Extend remote state collection so pull can identify remote files, directories, and possibly remote symlinks without relying on unsafe path strings.
- 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.
- Implement
calculate_pull_actions separately from calculate_sync_actions: downloads, local file deletions, local directory creations, and local directory deletions.
- 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.
- Preserve local safety: reject writes through local symlinks, reject paths escaping the sync root, and delete deepest-first.
- Add stats fields or direction-specific stats, for example
downloaded, deleted, and unchanged, without making push output confusing.
- 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.
- 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.
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 syncremains local -> remote push. Pull must be opt-in because it can overwrite or delete local files.Current implementation check
src/cli/sync.rsexposes push-only sync flags:--sync-root,--sync-cwd,--remote-dir,--force,--exclude, and--include.src/ssh/sync.rscurrently collects local state withcollect_local_state, fetches remote state withfetch_remote_state, calculates push actions withcalculate_sync_actions, then uploads via SFTP..gitignore/.biwaignore, and uses SHA-256 hash comparison unless--forceis set.sync.engine = "mutagen"is recognized in config but rejected bysync_project; this issue is SFTP-only.Proposed CLI
Add a warning to
--pullhelp that local files and directories missing from the remote source can be deleted.Behavior
--forceis set.sync_root, absolute remote paths,..traversal, and symlink traversal.Implementation plan
SyncDirection::{Push, Pull}, plus a--pullflag onSyncArgs.SyncArgs::resolve_optionsdirection-neutral. Pass direction into the sync entrypoint rather than overloadingOptionsif that keeps the API clearer.collect_local_state, but review how include/exclude patterns are absolutized today before applying them to local deletions.calculate_pull_actionsseparately fromcalculate_sync_actions: downloads, local file deletions, local directory creations, and local directory deletions.download_fileusing the existing SFTP session: open remote file read-only, create parent directories locally, write to a temporary local file, then atomically rename into place.downloaded,deleted, andunchanged, without making push output confusing.biwa runbehavior deliberately. IfSyncArgsis shared, either supportbiwa run --pull --sync ...with documented semantics or reject--pullonrunwith a clear error until there is a concrete use case.mise run render:usageafter CLI changes.Checks
--force, include/exclude, and deterministic ordering..., absolute paths, remote symlink entries, local symlink files/directories, and nested deletion ordering.biwa sync --pull, remote delete then local deletion, remote empty directory,--remote-dir,--sync-root,--include, and--exclude.--pullhelp/usage.cargo test --test ssh_e2e_sync,cargo test sync,mise run render:usage, andLINT=true mise run check.Docs updates
docs/src/sync-behavior.mdwith push vs pull direction, destructive deletion semantics, and safety notes.mise run render:usage.sync.directionsetting is added; otherwise keep direction CLI-only.Acceptance criteria
biwa syncremains push-only by default.biwa sync --pulldownloads remote changes and deletes local extras inside the sync root only.