Show HN: DriveSync – fast Git-styled Google Drive sync CLI

原始链接: https://github.com/scaleninja/drivesync

**dsync** 是一款基于 Rust 开发的高效命令行工具,旨在实现本地文件夹与 Google Drive 的同步。它支持推送本地更改、拉取远程更新以及比对目录差异以识别不一致之处。 **主要特性:** * **高效:** 使用 MD5 哈希、修改时间和文件大小来快速识别变更。本地缓存可维护文件状态,从而最大限度地减少冗余 API 调用。 * **安全:** 执行操作前会展示拟定计划并要求用户确认。内置了防止大小写冲突、符号链接遍历以及意外覆盖较新文件的保护机制。 * **可靠:** 通过可恢复流处理大文件上传,并支持网络中断时的自动重试。 * **可定制:** 支持通过 `.driveignore` 文件排除特定路径,并允许并行执行以加快处理速度。 **设置:** 用户必须创建一个启用了 Drive API 的 Google Cloud 项目以生成 OAuth 凭据。通过 `dsync init` 初始化后,该工具会在本地管理身份验证令牌。可通过 Homebrew、预编译二进制文件或源代码获取。 *注意:dsync 不支持三方合并(three-way merge),旨在用于本地文件系统(而非 NFS 等网络共享)。它会忽略原生 Google 文档、表格和幻灯片。*

“DriveSync” 是一款全新的快速命令行界面(CLI)工具,旨在为 Google Drive 提供类似 Git 的同步功能。该工具由开发者 `rohityadavcloud` 创建,专注于高效性,与 `rclone` 等功能更广泛的替代品相比,它提供了更快速的变更追踪、并行流处理以及可靠的断点续传功能。 在 Hacker News 的一场讨论中,开发者澄清称 DriveSync 目前遵循“不删除”的安全策略,即本地删除的文件不会自动从 Google Drive 中移除。不过,根据用户反馈,0.4.0 版本正在加入 `--delete` 覆盖标志。 虽然该工具最初因交叉编译挑战而仅限于 macOS 和 Linux 使用,但在社区协作下,开发者近期已增加了对 Windows 的支持。该项目开源并托管于 GitHub,定位为一款精简、专业的替代工具,适合那些偏好通过类似 Git 的工作流来管理 Google Drive 文件的用户。
相关文章

原文

A small, fast command-line tool that keeps a local folder and a Google Drive folder in sync. Push local changes up, pull remote changes down, or just see what differs. Written in Rust, modelled on odeke-em/drive.

dsync init ~/gdrive --remote-folder backups/lab --credentials ~/Downloads/client_secret.json
cd ~/gdrive
dsync diff      # what differs?
dsync push      # upload local changes (shows the plan, asks first)
dsync pull      # download remote changes

Homebrew (macOS and Linux):

brew install scaleninja/tap/drivesync

This adds the scaleninja/tap tap, so other scaleninja tools then install with a plain brew install <name>. (If Homebrew refuses with an "untrusted tap" error, run brew trust scaleninja/tap first.)

Or grab a prebuilt binary for Linux or macOS (x86_64 and arm64) from the releases page:

case "$(uname -s)-$(uname -m)" in
  Linux-x86_64)  asset=linux-x86_64 ;;   Linux-aarch64) asset=linux-arm64 ;;
  Darwin-x86_64) asset=macos-x86_64 ;;   Darwin-arm64)  asset=macos-arm64 ;;
  *) echo "unsupported platform"; exit 1 ;;
esac
curl -fL -o /tmp/dsync "https://github.com/scaleninja/drivesync/releases/latest/download/dsync-$asset" \
  && chmod 755 /tmp/dsync && sudo mv /tmp/dsync /usr/local/bin/dsync

Or from source with a Rust toolchain: git clone https://github.com/scaleninja/drivesync && cd drivesync && make install.

dsync talks to Drive with an OAuth client that you create, so no credentials are baked into the binary. One-time steps in the Google Cloud Console (there is a step-by-step walkthrough with screenshots):

  1. Create a project (or pick one).
  2. APIs & Services → Library: enable the Google Drive API.
  3. APIs & Services → OAuth consent screen: choose External, name the app (this name appears on the consent page), fill in the support emails, and add yourself under Test users.
  4. APIs & Services → Credentials → Create Credentials → OAuth client ID: type Desktop app, then Download JSON.

Then initialize a folder. A browser opens for consent; the tool receives the code on a loopback port and stores its tokens in .gd/ inside the folder (mode 0600, never uploaded).

dsync init ~/gdrive --remote-folder backups/lab --credentials ~/Downloads/client_secret.json

Google expires refresh tokens after 7 days for External apps still in Testing. If you are asked to re-run init every week, click Publish app on the consent screen (no verification is needed for personal use).

Run any command from anywhere inside the sync folder. PATH is relative to the current directory and may be a file or a folder; it defaults to ..

Command Does
init [DIR] --remote-folder P --credentials F Authorize and turn DIR into a sync folder mirroring My Drive/P. --depth N limits levels (-1 = unlimited).
push [PATH] Upload files that are new or newer locally. Shows the plan and asks first.
pull [PATH] Download files that are new or newer on Drive. Shows the plan and asks first.
diff [PATH] List what differs, with both modification times. Changes nothing; exit status 1 if anything differs.
status Local folder, remote folder, depth, cache state, ignore file, filesystem kind, token expiry.
update-cache Refresh the index of the remote tree only.
version Print the version.

Options for push, pull and diff:

Option Effect
-y, --no-prompt Apply without asking. Refused if the plan contains conflicts.
--force Also overwrite conflicts where the destination is newer or has different content at the same mtime. Case collisions are never forced.
-j N, --threads N Parallel streams (default 8, max 64).
--refresh Ignore the cached index and re-list the whole remote tree.
--fast rsync-style quick check: equal size and mtime is trusted without reading the file.
--verify Re-read every file that needs hashing instead of trusting the local hash cache.

Put gitignore-style patterns in .driveignore at the sync root to leave things out (init creates one with .DS_Store and ._*). .gd/, symlinks and non-regular files are never synced.

Exit status is 0 on success, 1 when diff found differences, and 2 on an error or when any transfer failed. PATH is taken in its on-disk spelling, so on macOS push Docs and push docs mean the same folder. Only one dsync command runs in a workspace at a time; a second one waits.

How it decides what changed

Each side is a set of paths with size, mtime and MD5. Drive computes MD5s server-side and returns them in listings, so the remote side is free. Files are compared cheapest check first:

  1. Different sizes → different, nothing is read.
  2. Otherwise the local MD5 is compared. It is computed once and cached against the file's size and mtime, so after the first run only files whose stat changed are read again (--verify bypasses the cache; --fast skips hashing when size and mtime match).
  3. Equal MD5 → identical, whatever the mtimes say.
  4. Different MD5 → the newer side (1 s tolerance) wins. Same mtime with different content is a conflict.

After a transfer both sides carry the same mtime, so diff reports nothing until something changes. The remote tree is indexed once in full, then kept current through the Drive Changes API, so repeated runs do not re-list Drive.

Push and pull print one line per change and ask before doing anything:

+ photos/2026/
+ photos/2026/a.jpg  1,234,567 B
M docs/report.txt  12,340 B, local newer
! docs/Budget  skipped: remote is a Google-native document; not overwritten
C notes.txt  conflict: remote is newer
Addition count 2 src: 1,234,567 B
Modification count 1 src: 12,340 B
Proceed with the changes? [Y/n]:

+ create · M overwrite · ! skipped for a structural reason · C conflict · E unreadable local file.

  • Delete anything, on either side. A file removed locally stays on Drive, and vice versa.
  • Overwrite a conflict without --force, or a name collision (case or Unicode normalization, on filesystems that fold them) at all. While conflicts exist the prompt defaults to no, --no-prompt refuses to run, and end-of-input is never taken as yes.
  • Write stale data. Every destination is re-checked right before it is written: a local file must still have the size and mtime the plan saw; a Drive file must still have the MD5, mtime, name and parent folder the plan saw, a file to be created must still be absent, and every folder new content goes into must still sit where the index placed it inside the sync tree. Anything that changed in between is reported and left alone. A local file that changes while it is being read or uploaded is reported as an error, and every upload is verified against the MD5 Drive computed.
  • Escape the sync folder. Nothing is written through a symlink, into .gd/, over an ignored path, or over a non-regular file. Downloads go to a private temp file, are verified against Drive's MD5, then renamed into place keeping the existing permissions.
  • Create duplicates. Drive is asked for a same-named entry immediately before every create, and creates use ids pre-generated by Drive, so neither a retry after a lost response nor a stale index makes a second copy or adopts someone else's file. Where Drive already holds duplicates, the oldest one consistently owns the path. Large uploads resume from the last byte Drive received, even across a restart: the session is kept in .gd/ for as long as the file's size and mtime are unchanged.
  • Touch Google Docs, Sheets or Slides. They have no binary content and are always skipped.

Files over 5 MB stream through resumable uploads; rate limits, network errors and stalled connections are retried with backoff; a push or pull interrupted with Ctrl-C can simply be re-run. If the remote folder is trashed or deleted on Drive, every command stops and says so. A damaged .gd/cache.db is rebuilt automatically; it is only an index.

  • Two machines syncing the same Drive folder are not coordinated; there is no three-way merge. The Drive API has no conditional update, so the check that a remote file is still what the plan saw and the write that replaces it are two requests; a write from elsewhere in that window is lost.
  • Pull keeps no local backup: a local file it overwrites (only ever an older one, unless --force) is gone. Push replaces Drive content, which Drive keeps as a revision for a while.
  • A full listing holds every My Drive entry in memory while the tree is assembled.
  • Another local process writing to the sync folder at the same moment as dsync is outside the supported threat model: destinations are checked immediately before each write, but not atomically with it.
  • Keep the sync folder on a local disk. On NFS or SMB shares neither the workspace lock nor the SQLite index behaves reliably.
  • The hash cache trusts an unchanged size and mtime, like git; use --verify after restoring files from a backup or when a tool rewrites files with their mtimes preserved.
  • Filesystem nuances: on macOS and other case-folding filesystems, names that differ only by case or Unicode normalization are one local file, so such pairs are reported as collisions and never transferred (HFS+ stores accented names in NFD form, which can trigger this for names created on Drive). Filesystems with coarse timestamps (FAT, exFAT, some network shares) cause more hashing but no wrong decisions, since content is compared by MD5. Names longer than 255 bytes cannot be local files and are skipped.
make build | release | test | check     # check = fmt + clippy
make all-targets                        # Linux (static musl via cargo-zigbuild) + macOS into dist/
make setup                              # one-time: rustup targets + cargo-zigbuild (needs zig)

Windows is not supported. CI runs fmt, clippy, tests and a locked build on every push and pull request; tags v* build all four targets and publish them with a SHA256SUMS file.

To ship builds to your own users with a bundled OAuth client, set both variables at build time; init then needs no --client-id/--client-secret. Google treats desktop client secrets as non-confidential, but a bundled client shares one API quota, so keep such builds internal:

DSYNC_CLIENT_ID=... DSYNC_CLIENT_SECRET=... cargo build --release
联系我们 contact @ memedata.com