A Python script to sync files and folders to a remote FTP/SFTP server based on a configuration file.
Install gsupload globally using uv so it's available from anywhere in your PATH:
# Install the tool
uv tool install --editable /path/to/gsupload-python
# Update your shell configuration to add the tool directory to PATH
uv tool update-shell
# Restart your shell or source your profile, then use directly:
gsupload --helpAfter installation, you can run gsupload from any directory without activating a virtual environment.
For development or if you prefer not to install globally:
- Clone the repository.
- Install dependencies:
uv pip install -e ".[dev]"- Run with:
bash
python src/gsupload.py [OPTIONS] PATTERNS... HOST_ALIAS
Configuration files are merged with inheritance - you can have global settings and project-specific overrides.
The tool searches for and merges multiple configuration files to provide maximum flexibility:
- Global config (optional base):
~/.gsupload/gsupload.jsonor~/.config/gsupload/gsupload.json - Project configs (layered): All
.gsupload.jsonfiles found walking up from current directory to filesystem root
Discovery Process:
- Checks if global config exists (if yes, loads as base layer)
- Walks up from your current directory collecting all
.gsupload.jsonfiles - Sorts configs from root → current directory (shallowest to deepest)
- Merges them layer by layer, with deeper configs overriding
Merging Rules:
| Configuration Key | Merge Strategy | Behavior |
|---|---|---|
global_excludes |
Additive | All patterns from all configs are combined into one list |
bindings |
Per-binding Override | Each binding can be added or overridden independently. Properties within a binding are merged (deeper values override). |
| Other top-level keys | Simple Override | Deeper config value replaces shallower value completely |
File structure:
~/.gsupload/gsupload.json
/projects/myapp/.gsupload.json
Global config (~/.gsupload/gsupload.json):
{
"global_excludes": [".DS_Store", "*.log"],
"bindings": {
"shared-staging": {
"protocol": "ftp",
"hostname": "staging.example.com",
"username": "deploy",
"password": "secret",
"local_basepath": "/projects",
"remote_basepath": "/www"
}
}
}Project config (/projects/myapp/.gsupload.json):
{
"global_excludes": [".git", "node_modules", "__pycache__"],
"bindings": {
"frontend": {
"protocol": "sftp",
"hostname": "app.example.com",
"username": "frontend-user",
"password": "pass123",
"local_basepath": "/projects/myapp/frontend",
"remote_basepath": "/var/www/html"
}
}
}Resulting merged config when running from /projects/myapp/:
{
"global_excludes": [
".DS_Store",
"*.log",
".git",
"node_modules",
"__pycache__"
],
"bindings": {
"shared-staging": {
"protocol": "ftp",
"hostname": "staging.example.com",
"username": "deploy",
"password": "secret",
"local_basepath": "/projects",
"remote_basepath": "/www"
},
"frontend": {
"protocol": "sftp",
"hostname": "app.example.com",
"username": "frontend-user",
"password": "pass123",
"local_basepath": "/projects/myapp/frontend",
"remote_basepath": "/var/www/html"
}
}
}Key takeaways:
- ✅
global_excludescombined from both files (5 total patterns) - ✅ Both bindings available:
shared-stagingfrom global,frontendfrom project
File structure:
/projects/myapp/.gsupload.json
/projects/myapp/dist_production/.gsupload.json
Project root config (/projects/myapp/.gsupload.json):
{
"global_excludes": ["*.log", ".git"],
"bindings": {
"frontend": {
"protocol": "sftp",
"hostname": "dev.example.com",
"port": 22,
"username": "devuser",
"password": "devpass",
"local_basepath": "/projects/myapp/frontend",
"remote_basepath": "/var/www/dev"
}
}
}Subdirectory config (/projects/myapp/dist_production/.gsupload.json):
{
"global_excludes": ["*.map"],
"bindings": {
"frontend": {
"hostname": "prod.example.com",
"username": "produser",
"password": "prodpass",
"remote_basepath": "/var/www/production"
}
}
}Resulting merged config when running from /projects/myapp/dist_production/:
{
"global_excludes": ["*.log", ".git", "*.map"],
"bindings": {
"frontend": {
"protocol": "sftp",
"port": 22,
"hostname": "prod.example.com",
"username": "produser",
"password": "prodpass",
"local_basepath": "/projects/myapp/frontend",
"remote_basepath": "/var/www/production"
}
}
}Key takeaways:
- ✅
global_excludesincludes patterns from both levels (3 total) - ✅
frontendbinding merged:protocolandportinherited from parent - ✅
hostname,username,password,remote_basepathoverridden by subdirectory config - ✅
local_basepathinherited from parent (not overridden)
Use the --show-config flag to see the final merged configuration with source annotations:
gsupload --show-configOutput example:
📋 Configuration Files (merge order):
1. /Users/user/.gsupload/gsupload.json
2. /projects/myapp/.gsupload.json
3. /projects/myapp/dist_production/.gsupload.json
🔀 Merged Configuration:
{
"global_excludes": ["*.log", ".git", "*.map"],
"bindings": {
"frontend": {
"protocol": "sftp",
"hostname": "prod.example.com",
"port": 22,
"username": "produser",
"password": "prodpass",
"local_basepath": "/projects/myapp/frontend",
"remote_basepath": "/var/www/production"
}
}
}
📍 Source Annotations:
global_excludes:
• *.log
↳ from: /projects/myapp/.gsupload.json
• .git
↳ from: /projects/myapp/.gsupload.json
• *.map
↳ from: /projects/myapp/dist_production/.gsupload.json
bindings:
• frontend
↳ defined in: /projects/myapp/.gsupload.json, /projects/myapp/dist_production/.gsupload.json
- protocol: from /projects/myapp/.gsupload.json
- hostname: from /projects/myapp/dist_production/.gsupload.json
- port: from /projects/myapp/.gsupload.json
- username: from /projects/myapp/dist_production/.gsupload.json
- password: from /projects/myapp/dist_production/.gsupload.json
- local_basepath: from /projects/myapp/.gsupload.json
- remote_basepath: from /projects/myapp/dist_production/.gsupload.json
This helps debug complex multi-layered configurations by showing exactly which file contributes each setting.
The local_basepath can be specified as:
- Absolute path:
/full/path/to/directory - Relative path (resolves relative to config file location):
.,./dist,../sibling - Omitted (defaults to config file's directory)
File structure:
/projects/webapp/.gsupload.json
/projects/webapp/frontend/.gsupload.json
/projects/webapp/admin/.gsupload.json
Project root config (/projects/webapp/.gsupload.json):
{
"bindings": {
"main": {
"protocol": "sftp",
"hostname": "main.example.com",
"username": "user",
"password": "pass",
"local_basepath": ".",
"remote_basepath": "/var/www/main"
}
}
}Frontend subdirectory (/projects/webapp/frontend/.gsupload.json):
{
"bindings": {
"frontend": {
"protocol": "sftp",
"hostname": "front.example.com",
"username": "frontuser",
"password": "frontpass",
"remote_basepath": "/var/www/frontend"
}
}
}Note: local_basepath omitted - will default to /projects/webapp/frontend
Admin subdirectory (/projects/webapp/admin/.gsupload.json):
{
"bindings": {
"admin": {
"protocol": "sftp",
"hostname": "admin.example.com",
"username": "adminuser",
"password": "adminpass",
"local_basepath": "../admin",
"remote_basepath": "/var/www/admin"
}
}
}Note: local_basepath is relative, resolves to /projects/webapp/admin
Resulting merged config when running from /projects/webapp/admin/:
{
"bindings": {
"main": {
"protocol": "sftp",
"hostname": "main.example.com",
"username": "user",
"password": "pass",
"local_basepath": "/projects/webapp",
"remote_basepath": "/var/www/main"
},
"frontend": {
"protocol": "sftp",
"hostname": "front.example.com",
"username": "frontuser",
"password": "frontpass",
"local_basepath": "/projects/webapp/frontend",
"remote_basepath": "/var/www/frontend"
},
"admin": {
"protocol": "sftp",
"hostname": "admin.example.com",
"username": "adminuser",
"password": "adminpass",
"local_basepath": "/projects/webapp/admin",
"remote_basepath": "/var/www/admin"
}
}
}Key takeaways:
- ✅
"."in root config resolved to/projects/webapp - ✅ Omitted
local_basepathin frontend config defaulted to/projects/webapp/frontend - ✅
"../admin"in admin config resolved to/projects/webapp/admin - ✅ All paths stored as absolute paths after resolution
- ✅ Makes configs portable and easier to maintain
File structure:
~/.gsupload/gsupload.json
/projects/webapp/.gsupload.json
/projects/webapp/admin/.gsupload.json
Global config (~/.gsupload/gsupload.json):
{
"global_excludes": [".DS_Store", "Thumbs.db"],
"bindings": {
"global-backup": {
"protocol": "ftp",
"hostname": "backup.example.com",
"username": "backup",
"password": "backup123",
"local_basepath": "/projects",
"remote_basepath": "/backups"
}
}
}Project root config (/projects/webapp/.gsupload.json):
{
"global_excludes": ["node_modules", ".env"],
"bindings": {
"frontend": {
"protocol": "sftp",
"hostname": "web.example.com",
"username": "frontend",
"password": "front123",
"local_basepath": "/projects/webapp/public",
"remote_basepath": "/var/www/public"
},
"backend": {
"protocol": "sftp",
"hostname": "api.example.com",
"username": "backend",
"password": "back456",
"local_basepath": "/projects/webapp/api",
"remote_basepath": "/var/www/api"
}
}
}Admin subdirectory config (/projects/webapp/admin/.gsupload.json):
{
"global_excludes": ["*.cache"],
"bindings": {
"admin-panel": {
"protocol": "sftp",
"hostname": "admin.example.com",
"username": "admin",
"password": "admin789",
"local_basepath": "/projects/webapp/admin",
"remote_basepath": "/var/admin"
},
"backend": {
"hostname": "admin-api.example.com",
"remote_basepath": "/var/www/admin-api"
}
}
}Resulting merged config when running from /projects/webapp/admin/:
{
"global_excludes": [
".DS_Store",
"Thumbs.db",
"node_modules",
".env",
"*.cache"
],
"bindings": {
"global-backup": {
"protocol": "ftp",
"hostname": "backup.example.com",
"username": "backup",
"password": "backup123",
"local_basepath": "/projects",
"remote_basepath": "/backups"
},
"frontend": {
"protocol": "sftp",
"hostname": "web.example.com",
"username": "frontend",
"password": "front123",
"local_basepath": "/projects/webapp/public",
"remote_basepath": "/var/www/public"
},
"backend": {
"protocol": "sftp",
"hostname": "admin-api.example.com",
"username": "backend",
"password": "back456",
"local_basepath": "/projects/webapp/api",
"remote_basepath": "/var/www/admin-api"
},
"admin-panel": {
"protocol": "sftp",
"hostname": "admin.example.com",
"username": "admin",
"password": "admin789",
"local_basepath": "/projects/webapp/admin",
"remote_basepath": "/var/admin"
}
}
}Key takeaways:
- ✅ All 5 exclude patterns combined from 3 config files
- ✅ 4 bindings available:
global-backupfrom global,frontendfrom project,backendmerged,admin-panelnew - ✅
backendbinding partially overridden:hostnameandremote_basepathchanged, other properties inherited - ✅ Can use any of these bindings:
gsupload -b=global-backup,-b=frontend,-b=backend,-b=admin-panel
Use Case 1: Development vs Production
/project/.gsupload.json # Dev settings with dev.example.com
/project/dist/.gsupload.json # Production overrides with prod.example.com
Run from /project/ for dev uploads, from /project/dist/ for production.
Use Case 2: Multi-environment Monorepo
/monorepo/.gsupload.json # Shared excludes and common bindings
/monorepo/frontend/.gsupload.json # Frontend-specific binding
/monorepo/backend/.gsupload.json # Backend-specific binding
/monorepo/admin/.gsupload.json # Admin-specific binding
Each directory has its own binding, all share global excludes.
Use Case 3: Team-wide Defaults
~/.gsupload/gsupload.json # Your personal global settings
/projects/team-app/.gsupload.json # Team's shared project config (versioned in git)
Personal settings in home directory, project settings shared with team via version control.
When running from /projects/myapp/subfolder/deep/:
- First loaded (lowest priority):
~/.gsupload/gsupload.jsonor~/.config/gsupload/gsupload.json /projects/.gsupload.json(if exists)/projects/myapp/.gsupload.json(if exists)/projects/myapp/subfolder/.gsupload.json(if exists)/projects/myapp/subfolder/deep/.gsupload.json(if exists)- Last merged (highest priority): Closest to cwd
Each level can:
- ✅ Add new patterns to
global_excludes - ✅ Add new bindings
- ✅ Override properties of existing bindings
- ✅ Replace other top-level settings completely
{
"bindings": {
"frontend": {
"protocol": "sftp",
"hostname": "example.com",
"port": 22,
"username": "user",
"password": "password",
"key_filename": "/path/to/private/key",
"max_workers": 10,
"local_basepath": "/Users/gustavo/dev/project",
"remote_basepath": "/var/www/html"
},
"admin": {
"protocol": "ftp",
"hostname": "ftp.example.com",
"port": 21,
"username": "admin",
"password": "secretpassword",
"max_workers": 1,
"local_basepath": "/Users/gustavo/dev/project/admin",
"remote_basepath": "/public_html/admin",
"comments": "FTP with conservative max_workers due to server connection limits"
}
}
}SFTP Authentication Methods:
-
SSH Agent (recommended): Omit both
passwordandkey_filename{ "protocol": "sftp", "username": "user" // No password or key_filename - uses SSH agent } -
Password Authentication: Provide only
password{ "protocol": "sftp", "username": "user", "password": "your-password" } -
Unencrypted SSH Key: Provide only
key_filename{ "protocol": "sftp", "username": "user", "key_filename": "/path/to/unencrypted_key" } -
Encrypted SSH Key: Provide both
key_filenameandpassword{ "protocol": "sftp", "username": "user", "key_filename": "/path/to/encrypted_key", "password": "key-passphrase" }Note: The
passwordfield serves dual purpose - SSH authentication password OR key passphrase whenkey_filenameis provided
Path Configuration:
local_basepathcan be:- Absolute path:
/full/path/to/directory - Relative path:
.(current config directory),./dist,../sibling(resolves relative to config file location) - Omitted: Defaults to the directory containing the config file
- Absolute path:
- Using
.or omittinglocal_basepathmakes configs portable and easier to maintain
Performance Configuration:
max_workers(optional): Number of parallel upload workers (default: 5)- SFTP: Parallel uploads work reliably
- FTP: Parallel uploads may be limited or blocked by some servers (use 1-3 workers if issues occur)
- Higher values = faster uploads but more resource usage
- Recommended: 5-10 for SFTP, 1-3 for FTP
- Can be overridden with
--max-workersCLI flag
You can exclude files from being uploaded in three ways:
- Global Excludes: Add a
global_excludeslist to the top level of your configuration file. - Host Excludes: Add an
excludeslist to a specific host configuration. - Folder Excludes: Create a
.gsupload_ignorefile in any directory. The script walks up from the current directory to the project root, collecting all.gsupload_ignorefiles found along the way. Exclude patterns are additive - all ignore files in parent directories are also applied.
Supported Patterns:
*.log: Matches any file ending in.login any directory.node_modules: Matches any file or folder namednode_modulesin any directory./dist: Matchesdistfolder only at the root (relative tolocal_basepathor.gsupload_ignorelocation).src/*.tmp: Matches.tmpfiles directly insidesrc.src/**/*.tmp: Matches.tmpfiles recursively insidesrc.
Example configuration with excludes:
{
"global_excludes": [
".DS_Store",
"*.log",
".git"
],
"bindings": {
"frontend": {
...
"excludes": [
"node_modules",
"secrets.js"
]
}
}
}Example .gsupload_ignore:
# Ignore all temporary files
*.tmp
# Ignore specific config
local_config.php
Use --show-ignored to see which files and directories are being excluded:
# Show all ignored items (recursive, auto-detect binding)
gsupload --show-ignored
# Show ignored items in current directory only
gsupload --show-ignored -nr
# Show ignored items for specific binding
gsupload --show-ignored -b=frontendExample output:
🚫 Ignored Files and Directories:
Scanning from: /projects/webapp
Mode: Recursive
Active exclude patterns:
• .DS_Store
• node_modules
• *.log
• .git
• *.map
Found 6 ignored items:
📄 .DS_Store
📁 .git
📄 .gsupload.json
📄 dist/bundle.map
📁 node_modules
📄 test.log
Total items scanned: 14
This helps verify your exclude patterns are working as expected.
gsupload [OPTIONS] PATTERNS...Default Behavior:
By default, gsupload operates in recursive mode with complete visual check (-r -vcc). This means:
- ✅ Glob patterns like
"*.css"search recursively through all subdirectories - ✅ Shows complete tree comparison (local + remote files) before upload
- ✅ Requires confirmation before proceeding
To disable these defaults, use -nr (no recursive) or -nvcc (no visual check complete).
Visual Check Modes:
- Default (or
-vcc): Shows complete tree including remote-only files (files that exist on server but not locally) - Changes only (
-vc): Shows only files that will be uploaded (new or overwritten), excludes remote-only files - No visual check (
-nvccor-f): Skips tree comparison entirely
All visual check modes only scan files within the remote_basepath directory, never the entire server filesystem.
Options:
-r, --recursive/-nr, --no-recursive- Search recursively in subdirectories [default: enabled]-vc, --visual-check- Display tree comparison showing only changes (new/overwritten files, excludes remote-only)-vcc, --visual-check-complete/-nvcc, --no-visual-check-complete- Display complete tree comparison including remote-only files [default: enabled]--max-depth- Maximum tree depth to display in visual check (default: 20)-ts, --tree-summary- Show summary statistics only, skip tree display in visual check-f, --force- Force upload without confirmation or remote file check (fastest mode, disables visual check)-b, --binding- Binding alias from configuration. If omitted, auto-detects from current directory--show-config- Display the merged configuration with source file annotations and exit--show-ignored- List all files and directories that are being ignored by exclude patterns and exit--max-workers- Number of parallel upload workers for faster transfers (default: 5, overrides binding config)--ftp-active- Use FTP active mode instead of passive mode (PASV). Passive mode is recommended for most networks.
Performance Note: By default, gsupload uses 5 parallel workers with SSH compression (SFTP) and passive mode (FTP) for significantly faster uploads. See PERFORMANCE.md for details.
Arguments:
PATTERNS- One or more file patterns, filenames, or directories to upload
When using glob patterns (like *.txt, *.css, or src/**/*.js), you MUST quote them to prevent shell expansion. Without quotes, your shell will expand the pattern before gsupload sees it, which will cause unexpected behavior or errors.
Why quotes are necessary:
- Shell expansion happens before your program runs
- By the time
gsuploadreceives arguments, the shell has already expanded*.csstofile1.css file2.css - Your program never sees the original pattern
- This is fundamental to how all shells (bash, zsh, fish) work
This is standard practice across Unix tools:
find . -name "*.txt"- requires quotesgrep "pattern" *.log- requires quotes for the patterngit add "*.js"- requires quotesrsync "*.css" remote:/path/- requires quotes
The requirement to quote patterns is correct and matches industry standards.
# ✅ CORRECT - Pattern is quoted, gsupload handles the glob
gsupload -r "*.txt"
gsupload -b=frontend "*.css"
gsupload -b=backend "src/**/*.js"
# ❌ WRONG - Shell expands the pattern before gsupload sees it
gsupload -r *.txt # Shell expands to: gsupload -r file1.txt file2.txt ...
gsupload -r src/**/*.js # May fail or behave unexpectedlyExample: Without quotes, if you have file1.txt and file2.txt in your current directory and run gsupload -r *.txt, your shell will expand this to gsupload -r file1.txt file2.txt, which only uploads those two files instead of recursively finding all .txt files as intended.
Inspect configuration:
gsupload --show-config # Display merged config with source annotationsList ignored files:
gsupload --show-ignored # List all ignored files/dirs (recursive, auto-detect binding)
gsupload --show-ignored -nr # List ignored items in current directory only
gsupload --show-ignored -b=frontend # List ignored items for specific bindingBasic usage (uses defaults: recursive + complete visual check):
gsupload "*.css" # Recursively finds all CSS, shows complete tree, asks confirmation
gsupload -b=frontend "*.js" # Same with explicit bindingVisual check with changes only (excludes remote-only files):
gsupload -vc "*.css" # Shows only files that will be uploaded (new/overwrite)
gsupload -vc -b=frontend "*.js" # Faster check, skips listing remote-only filesUpload without visual check (fast mode):
gsupload -f -b=frontend "*.css" # Force mode: no confirmation, no remote check
gsupload -nvcc -b=frontend "*.css" # Disable visual check but still uploadParallel uploads (performance tuning):
gsupload "*.css" # Default: 5 workers (or binding config value)
gsupload --max-workers=10 "*.css" # Override with 10 workers
gsupload --max-workers=1 "*.css" # Sequential (for debugging)Non-recursive upload (current directory only):
gsupload -nr -b=frontend "*.css" # Only files in current directoryVisual check with changes only:
gsupload -vc -b=frontend "*.css" # Shows only files that will change (not remote-only)Custom tree depth:
gsupload --max-depth=5 -b=backend "*.js" # Limit tree display depthShow summary statistics only (no tree display):
gsupload -vc -ts -b=frontend "*.html"Upload directories:
gsupload -b=frontend src/assets # Uploads all files in directory (recursive by default)Upload multiple specific files:
gsupload -b=frontend index.html style.css app.jsComplex patterns:
gsupload -b=backend "src/**/*.js" # All JS files in src and subdirectoriesNote: If you installed locally without uv tool, use python src/gsupload.py instead of gsupload.
The script calculates the remote path relative to local_basepath defined in the configuration.