-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfolder-sync.py
More file actions
173 lines (145 loc) · 6 KB
/
folder-sync.py
File metadata and controls
173 lines (145 loc) · 6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
#!/usr/bin/env python3
"""
Folder Sync — Two-way folder synchronization that mirrors deletions.
Usage:
python folder-sync.py <source> <replica>
python folder-sync.py <source> <replica> --dry-run
python folder-sync.py <source> <replica> --exclude .git --exclude __pycache__
Options:
--dry-run Show what would be done without making changes
--exclude PAT Exclude files/dirs matching pattern (can be used multiple times)
--ignore-hidden Ignore hidden files/directories
--help Show this help message and exit
Behavior:
- Copies new/modified files from source to replica
- Deletes files in replica that don't exist in source
- Preserves directory structure
"""
import os
import sys
import shutil
import argparse
import filecmp
from pathlib import Path
def should_exclude(name: str, exclude_patterns: list[str], ignore_hidden: bool) -> bool:
"""Check if a file/dir name matches any exclusion pattern."""
if ignore_hidden and name.startswith("."):
return True
for pat in exclude_patterns:
if pat in name or name == pat:
return True
return False
def sync_folders(source: Path, replica: Path, dry_run: bool,
exclude: list[str], ignore_hidden: bool) -> tuple[int, int, int]:
"""
Sync replica to match source. Returns (copied, deleted, errors).
"""
copied = deleted = errors = 0
# Ensure replica exists
if not replica.exists():
if dry_run:
print(f" [DRY-RUN] mkdir {replica}")
else:
replica.mkdir(parents=True, exist_ok=True)
copied += 1 # count as an operation
# Walk source directory
for root, dirs, files in os.walk(source):
rel_root = Path(root).relative_to(source)
dest_root = replica / rel_root
# Filter dirs in-place to prevent os.walk from descending into excluded dirs
dirs[:] = [d for d in dirs
if not should_exclude(d, exclude, ignore_hidden)]
# Create destination subdirectory if needed
if not dest_root.exists():
if dry_run:
print(f" [DRY-RUN] mkdir {dest_root}")
else:
dest_root.mkdir(parents=True, exist_ok=True)
# Copy/modify files
for fname in files:
if should_exclude(fname, exclude, ignore_hidden):
continue
src_file = Path(root) / fname
dst_file = dest_root / fname
if not dst_file.exists() or not filecmp.cmp(src_file, dst_file, shallow=False):
if dry_run:
print(f" [DRY-RUN] cp {src_file} → {dst_file}")
else:
try:
shutil.copy2(src_file, dst_file)
except Exception as e:
print(f" ✗ Error copying {fname}: {e}")
errors += 1
continue
copied += 1
# Delete files in replica that don't exist in source
for root, dirs, files in os.walk(replica):
rel_root = Path(root).relative_to(replica)
src_root = source / rel_root
for fname in files:
if should_exclude(fname, exclude, ignore_hidden):
continue
dst_file = Path(root) / fname
src_file = src_root / fname
if not src_file.exists():
if dry_run:
print(f" [DRY-RUN] rm {dst_file}")
else:
try:
dst_file.unlink()
except Exception as e:
print(f" ✗ Error deleting {fname}: {e}")
errors += 1
continue
deleted += 1
# Remove empty directories in replica
for d in sorted(dirs, reverse=True):
dir_path = Path(root) / d
src_dir = src_root / d
if not src_dir.exists() and dir_path.exists():
try:
if dry_run:
print(f" [DRY-RUN] rmdir {dir_path}")
else:
if not any(dir_path.iterdir()):
dir_path.rmdir()
deleted += 1
except (OSError, Exception) as e:
errors += 1
return copied, deleted, errors
def main():
parser = argparse.ArgumentParser(
description="Synchronise two folders, mirroring deletions.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Examples:\n"
" python folder-sync.py /path/to/source /path/to/replica\n"
" python folder-sync.py ./original ./backup --dry-run\n"
" python folder-sync.py ./src ./dst --exclude .git --exclude node_modules\n"
" python folder-sync.py ./a ./b --ignore-hidden\n"
),
)
parser.add_argument("source", help="Source directory")
parser.add_argument("replica", help="Replica directory (will mirror source)")
parser.add_argument("--dry-run", action="store_true", help="Preview changes without making any")
parser.add_argument("--exclude", action="append", default=[], help="Exclude pattern (repeatable)")
parser.add_argument("--ignore-hidden", action="store_true", help="Skip hidden files/dirs")
args = parser.parse_args()
source = Path(args.source).resolve()
replica = Path(args.replica).resolve()
if not source.is_dir():
print(f"Error: Source '{args.source}' is not a valid directory.")
sys.exit(1)
if source == replica:
print("Error: Source and replica must be different directories.")
sys.exit(1)
label = " [DRY-RUN]" if args.dry_run else ""
print(f"Syncing{label}: {source} → {replica}")
copied, deleted, errors = sync_folders(
source, replica, args.dry_run, args.exclude, args.ignore_hidden
)
print(f"\nSummary: {copied} copied/created, {deleted} deleted, {errors} errors")
if errors:
sys.exit(1)
if __name__ == "__main__":
main()