11#!/usr/bin/env python3
22# -*- coding: utf-8 -*-
33
4+ from datetime import datetime
45import argparse
6+ import os
57
68
7- """
8- -r|--remove|--remove-folder --> Removes the backup folder after the backup
9- -s|--scratch|--from-scratch --> Empties the backupb folder previous to start the backup
10- -c|--compress --> Produces a compressed file with the backup folders
11- -p|--compressed-path --> custom folder where the compressed file will be stored. Needs value., Implies -c
12- -h|--hierarchy|--hierarchy-backup --> Clone repos keeping organization and user hierarchy in backup folder
13- -j|--json|--generate-json --> generates a json of the of the backup up folders and repos
14- -J|--json-path --> custom folder where the json report will be stored
15- -b|--backup|--backup-path --> custom folder where all the clones will be done. Needs value
16- -l|--gitlab -> Custom user that will be backed from gitlab. needs one or more value
17- -f|--force --> Does not ask confirmation for destructive actions
18- """
9+ def is_file_directory_writable (file_path ):
10+ if os .access (os .path .dirname (file_path ), os .W_OK ):
11+ return True
12+ else :
13+ return False
1914
2015
21- def main ():
16+ def is_file_writable (file_path ):
17+ try :
18+ # Try opening file in write mode
19+ open (file_path , 'w' )
20+ return True
21+ except (OSError , PermissionError ):
22+ return False
23+
24+
25+ def parse_arguments ():
2226 parser = argparse .ArgumentParser (description = "Backup your git repos in your local filesystem" )
2327
2428 # Optional arguments
29+ parser .add_argument ("-p" , "--provider" , "--providers" ,
30+ type = str ,
31+ nargs = "+" , # Allow one or more values
32+ help = "Adds custom GitLab providers" ,
33+ dest = "custom_providers" )
2534 parser .add_argument ("-r" , "--remove" , "--remove-folder" ,
2635 action = "store_true" ,
2736 help = "Removes the backup folder after the backup." ,
@@ -37,17 +46,32 @@ def main():
3746 help = "Produces a compressed file with the backup folders." ,
3847 default = False ,
3948 dest = "produce_compressed" )
40- parser .add_argument ("-p " , "--compressed-path" ,
49+ parser .add_argument ("-C " , "--compressed-path" ,
4150 type = str ,
4251 metavar = "PATH" ,
4352 help = "Custom path where the compressed file will be stored. Implies -c." ,
44- default = "backup/backup.tar.gz" ,
4553 dest = "compressed_path" )
46- parser .add_argument ("-h " , "--hierarchy" , "--hierarchy-backup" , "--keep-hierarchy" ,
54+ parser .add_argument ("-y " , "--hierarchy" , "--hierarchy-backup" , "--keep-hierarchy" ,
4755 action = "store_true" ,
4856 help = "Clone repos keeping organizations and user hierarchy in the backup folder." ,
4957 default = False ,
5058 dest = "reflect_hierarchy" )
59+ parser .add_argument ("-F" , "--flatten" , "--Flatten" ,
60+ type = str ,
61+ choices = ["user" , "provider" , "organization" ],
62+ nargs = "+" , # Allow one or more values
63+ help = "Directory levels that will be flatten in the backup folder. Implies -y except when "
64+ "flattening all directory level" ,
65+ dest = "flatten_directories" )
66+ parser .add_argument ("-l" , "--handle-collision" , "--handle-collision-strategy" ,
67+ choices = ["rename" , "ignore" , "remove" ],
68+ help = "Strategy to follow to handle collisions (a repo that because of its name has to be cloned"
69+ " in the same folder path as another one):"
70+ "rename: Use a systematic name (the shortest possible) for the new repo that produces the"
71+ " collision"
72+ "ignore: Ignores repos that have filename collisions."
73+ "remove: Removes the repo already cloned and clones the new one with the same name." ,
74+ dest = "collision_strategy" )
5175 parser .add_argument ("-j" , "--json" , "--generate-json" , "--produce-json" ,
5276 action = "store_true" ,
5377 help = "Generates a JSON report of the backup folders and repos." ,
@@ -57,20 +81,12 @@ def main():
5781 type = str ,
5882 metavar = "PATH" ,
5983 help = "Custom path where the JSON report will be stored. Implies -j." ,
60- default = "backup/backup.json" ,
6184 dest = "json_path" )
6285 parser .add_argument ("-b" , "--backup" , "--backup-path" ,
6386 type = str ,
6487 metavar = "PATH" ,
6588 help = "Custom folder where all the clones will be done. Requires a value." ,
66- default = "backup" ,
6789 dest = "backup_path" )
68- parser .add_argument ("-l" , "--gitlab" ,
69- type = str ,
70- metavar = "GITLAB_USERS" ,
71- nargs = "+" ,
72- help = "One or more GitLab usernames to back up. Separate multiple usernames with spaces." ,
73- dest = "gitlab_users" )
7490 parser .add_argument ("-f" , "--force" ,
7591 action = "store_true" ,
7692 help = "Does not ask confirmation for destructive actions." ,
@@ -81,65 +97,166 @@ def main():
8197 help = "Enable verbose output during backup." ,
8298 default = False ,
8399 dest = "is_verbose" )
84- parser .add_argument ("github_users" ,
85- type = str ,
86- metavar = "GITHUB_USERS" ,
87- nargs = "*" , # Zero or more GitHub usernames
88- help = "One or more GitHub usernames to back up. Separate multiple usernames with spaces. "
89- "Positional argument" ,
90- dest = "github_users" )
100+ parser .add_argument ("--exclude-github" ,
101+ action = "store_true" ,
102+ help = "Exclude GitHub repositories from the backup." )
103+ parser .add_argument ("--exclude-gitlab" ,
104+ action = "store_true" ,
105+ help = "Exclude GitLab repositories from the backup." )
106+ # Positional argument for usernames of the profiles to scrap
107+ parser .add_argument ("usernames" ,
108+ nargs = "+" ,
109+ metavar = "USERNAME1 USERNAME2 USERNAME3 ..." ,
110+ help = "List of usernames to back up from GitHub and GitLab." )
91111
92112 args = parser .parse_args ()
93113
94- # Check if at least one of the user lists is provided (GitHub or GitLab)
95- if not args .github and not args .gitlab :
96- parser .error ("You must provide at least one GitHub or GitLab user." )
114+ # Generate same date string for all entities
115+ datetime_string = datetime .now ().strftime ("%Y-%m-%dT%H:%M:%S" ) # ISO 8601-like format
116+
117+ # Supply default backup directory
118+ if not args .backup_path :
119+ args .backup_path = os .path .join (os .path .dirname (os .path .abspath (__file__ )), "backup" + datetime_string )
120+
121+ # Check existence and access of backup directory
122+ if not os .path .exists (args .backup_path ):
123+ try :
124+ os .makedirs (args .backup_path )
125+ except OSError as e :
126+ parser .error (f"The folder for the backup { args .backup_path } does not exist and cannot be created." )
127+ else :
128+ if not os .access (args .backup_path , os .W_OK ):
129+ parser .error (f"The folder for the backup { args .backup_path } cannot be written or there is some problem "
130+ f"with it: " )
131+
132+ # Flattening all directory levels makes hierarchy disappears, so it makes no sense to select both configurations
133+ if args .flatten_directories \
134+ and "rename" in args .flatten_directories \
135+ and "ignore" in args .flatten_directories \
136+ and "remove" in args .flatten_directories \
137+ and args .reflect_hierarchy :
138+ parser .error ("You cannot use -y when flattening all directory levels with -F because it makes no sense." )
139+
140+ # When keeping the complete hierarchy we ensure that there will be no collisions
141+ if not args .flatten_directories \
142+ and args .reflect_hierarchy \
143+ and args .collision_strategy :
144+ parser .error ("You do not need to supply a collision strategy with -l when keeping the hierarchy with -y and not"
145+ " flattening any directory (not supplying -F)" )
97146
98- # Compressed path implies -c
99- if args .compressed_path :
147+ # Supply default collision strategy if needed
148+ if args .reflect_hierarchy \
149+ and args .flatten_directories \
150+ and not args .collision_strategy :
151+ args .collision_strategy = "rename"
152+
153+ if not args .collision_strategy :
154+ args .collision_strategy = "rename"
155+
156+ # If path supplied -c implicit
157+ if not args .produce_compressed and args .compressed_path :
100158 args .produce_compressed = True
101159
102- # JSON path implies -j
103- if args .json_path :
160+ # If -c but no path provided set to default value
161+ if args .produce_compressed and not args .compressed_path :
162+ args .compressed_path = os .path .join (os .path .dirname (os .path .abspath (__file__ )), "backup" + datetime_string +
163+ ".tar.gz" )
164+
165+ # Check access to json file
166+ if args .compressed_path and not is_file_directory_writable (args .compressed_path ):
167+ parser .error ("File " + args .compressed_path + " is not writable because its directory cannot be accessed." )
168+
169+ # Check write access to json file
170+ if args .compressed_path and not is_file_writable (args .compressed_path ):
171+ parser .error ("File " + args .compressed_path + " is not writable." )
172+
173+ # If path supplied -j implicit
174+ if not args .produce_json and args .json_path :
104175 args .produce_json = True
105176
106- print (args )
177+ # If -j but no path provided set to default value
178+ if args .produce_json and not args .json_path :
179+ args .json_path = os .path .join (os .path .dirname (os .path .abspath (__file__ )), "backup" + datetime_string + ".json" )
180+
181+ # Check access to json file
182+ if args .json_path and not is_file_directory_writable (args .json_path ):
183+ parser .error ("File " + args .json_path + " is not writable because its directory cannot be accessed." )
184+
185+ # Check write access to json file
186+ if args .json_path and not is_file_writable (args .json_path ):
187+ parser .error ("File " + args .json_path + " is not writable." )
107188
108- print ("Backup summary:" )
189+ # Check if at least one source is included
190+ if args .exclude_github and args .exclude_gitlab and not args .custom_providers :
191+ parser .error ("You cannot exclude both GitHub and GitLab and not supply a custom provider. At least one provider"
192+ " must be included." )
109193
110- if args .github_users :
111- print ("Backing up the following GitHub users:" )
112- for user in args .github_users :
113- print (f" - { user } " )
194+ return args
114195
115- if args .gitlab :
116- print ("Backing up the following GitLab users:" )
117- for user in args .gitlab :
118- print (f" - { user } " )
196+
197+ def print_summary (args ):
198+ print ("BACKUP SUMMARY:" )
199+ print ("Backing up the following users:" )
200+ for user in args .usernames :
201+ print (f" - { user } " )
202+
203+ print ("Using the following providers:" )
204+ for provider in args .custom_providers :
205+ print (f" - { provider } " )
206+ print (" - GitHub (github.com)" )
207+ print (" - GitLab (gitlab.com)" )
119208
120209 if args .backup_path :
121- print ("Backup folder: " + args .backup_path )
210+ print ("* Backup folder: " + args .backup_path )
122211
123212 if args .produce_compressed :
124- print ("Compressed backup path: " + args .compressed_path )
213+ print ("* Compressed backup path: " + args .compressed_path )
125214
126215 if args .produce_json :
127- print ("JSON summary path: " + args .json_path )
216+ print ("* JSON summary path: " + args .json_path )
128217
218+ print ("* Empty backup folder before performing backup (start from scratch): " , end = "" )
129219 if args .empty_backup_folder_first :
130- print ("Empty backup folder before performing backup (start from scratch): " + args .empty_backup_folder_first )
220+ print ("Yes" )
221+ else :
222+ print ("No" )
131223
224+ print ("* Remove backup folder after performing backup: " , end = "" )
132225 if args .remove_backup_folder_afterwards :
133- print ("Remove backup folder after performing backup: " + args .remove_backup_folder_afterwards )
226+ print (" Yes" )
227+ else :
228+ print (" No" )
134229
230+ print ("* Produces a hierarchical structure for the backup: " , end = "" )
135231 if args .reflect_hierarchy :
136- print ("Produces a hierarchical structure for the backup: " + args .remove_backup_folder_afterwards )
232+ print (" Yes" )
233+ else :
234+ print (" No" )
235+
236+ if args .flatten_directories :
237+ print ("* Directory levels to flatten: " +
238+ ", " .join (args .flatten_directories ))
239+
240+ if args .collision_strategy :
241+ print ("* Strategy to avoid collision in the folder names of the repos: " + args .collision_strategy )
137242
138- if args .verbose :
139- print ("Verbose: " + args .verbose )
243+ print ("* Verbose output: " , end = "" )
244+ if args .is_verbose :
245+ print (" Yes" )
246+ else :
247+ print (" No" )
140248
141- if args .force :
142- print ("Force: " + args .force )
249+ print ("* Do not ask for confirmation on destructive actions: " , end = "" )
250+ if args .is_forced :
251+ print (" Yes" )
252+ else :
253+ print (" No" )
254+
255+
256+ def main ():
257+ args = parse_arguments ()
258+ if args .is_verbose :
259+ print_summary (args )
143260
144261
145262if __name__ == "__main__" :
0 commit comments