summaryrefslogtreecommitdiff
path: root/gcl
diff options
context:
space:
mode:
Diffstat (limited to 'gcl')
-rwxr-xr-xgcl188
1 files changed, 188 insertions, 0 deletions
diff --git a/gcl b/gcl
new file mode 100755
index 0000000..b973ae2
--- /dev/null
+++ b/gcl
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+# git config linker (gcl) v1.0
+# Written by Martin Fischer <martin@push-f.com> and licensed under MIT.
+import sys
+import os
+import configparser
+import subprocess
+import shutil
+
+GITCONFIGS = 'gitconfigs/'
+
+class colors:
+ WARNING = '\033[93m'
+ FAIL = '\033[91m'
+ ENDC = '\033[0m'
+
+def print_usage():
+ print('''git config linker (gcl):
+
+ gcl status check the status of the directories in .
+
+ gcl link <repo>... move <repo>/.git/config to gitconfigs/<repo>
+ (leaving a symlink behind)
+
+ gcl clone list all configs that are not cloned
+
+ gcl clone <config>... clone a repository using gitconfigs/<config>
+
+ gcl mv <src> <dest> rename a repo and its config and update the symlink
+
+ gcl unlink <repo>... undo "gcl link <repo>"
+ (only needed if you want to stop using gcl)
+''')
+ sys.exit()
+
+args = sys.argv[1:]
+if len(args) == 0:
+ print_usage()
+
+if not os.path.exists(GITCONFIGS + '/.git/config'):
+ sys.exit(f'expected {GITCONFIGS} to be a git repository')
+
+def get_link_error(path):
+ configpath = path + '/.git/config'
+ if not os.path.exists(configpath):
+ missing = os.path.relpath(os.path.realpath(configpath))
+ return colors.FAIL + 'BROKEN LINK' + colors.ENDC + f' ({missing} does not exist)'
+ elif os.path.islink(configpath) and os.path.relpath(os.path.realpath(configpath)) != GITCONFIGS + path:
+ missing = os.path.relpath(os.path.realpath(configpath))
+ return colors.WARNING + 'LINK MISMATCH' + colors.ENDC + f' ({missing})'
+
+def get_repo_status(path):
+ errors = []
+ link_error = get_link_error(path)
+ if link_error:
+ errors.append(link_error)
+
+ if hasremote(path + '/.git/config'):
+ try:
+ if subprocess.check_output(['git', 'branch', '-r', '--contains', 'HEAD'], cwd=path, stderr=subprocess.DEVNULL) == b'':
+ errors.append(colors.WARNING + 'UNPUSHED' + colors.ENDC)
+ except subprocess.CalledProcessError as err:
+ if subprocess.run(['git', 'rev-parse', 'HEAD'], cwd=path, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL) == 0:
+ # the first command did not just fail because there are no commits yet
+ raise err
+ else:
+ errors.append(colors.FAIL + 'NO REMOTE' + colors.ENDC)
+
+ if subprocess.check_output(['git', 'status', '--porcelain'], cwd=path) != b'':
+ errors.append(colors.WARNING + 'DIRTY' + colors.ENDC)
+ return ', '.join(errors)
+
+def print_repo_table(reponames):
+ maxlen = max([len(s) for s in reponames])
+ for reponame in reponames:
+ print(f'\t{reponame.ljust(maxlen)} {get_repo_status(reponame)}')
+ print()
+
+linked = []
+unlinked = []
+not_a_repo = []
+
+def hasremote(path):
+ config = configparser.ConfigParser()
+ config.read(path)
+ return any([':' in config[s].get('url','') for s in config.sections() if s.startswith('remote')])
+
+def scan():
+ for filename in sorted(os.listdir()):
+ if not os.path.isdir(filename):
+ continue
+
+ configpath = filename + '/.git/config'
+ if os.path.islink(configpath):
+ linked.append(filename)
+ elif not os.path.exists(configpath):
+ not_a_repo.append(filename)
+ else:
+ unlinked.append(filename)
+
+cmd = args.pop(0)
+
+if cmd == 'status':
+ scan()
+ if not_a_repo:
+ print(f"non-repositories:\n")
+ print('\t' + '\n\t'.join(not_a_repo))
+ print()
+ if unlinked:
+ print('unlinked repos:\n (link with "gcl link <name>...")\n')
+ print_repo_table(unlinked)
+ if linked:
+ print('linked repos:\n')
+ print_repo_table(linked)
+
+elif cmd == 'link':
+ if len(args) == 0:
+ sys.exit('Nothing specified, nothing linked.')
+
+ for repo in args:
+ configpath = repo + '/.git/config'
+ if not os.path.isfile(configpath):
+ print(f"skipping '{repo}' because {configpath} is not a file (maybe already linked?)")
+ continue
+ if os.path.exists(GITCONFIGS + repo):
+ sys.exit(f"aborting since {GITCONFIGS + repo} already exists")
+ os.rename(configpath, GITCONFIGS + repo)
+ os.symlink('../../' + GITCONFIGS + repo, configpath)
+
+elif cmd == 'unlink':
+ if len(args) == 0:
+ sys.exit('Nothing specified, nothing unlinked.')
+
+ for repo in args:
+ configpath = repo + '/.git/config'
+ if not os.path.islink(configpath):
+ continue
+ if not os.path.isfile(configpath):
+ print(f"skipping '{repo}' because {config} is not a file")
+ continue
+
+ os.unlink(configpath)
+ shutil.copyfile(GITCONFIGS + repo, configpath)
+ os.unlink(GITCONFIGS + repo)
+
+elif cmd == 'clone':
+ if len(args) == 0:
+ scan()
+ available = set(os.listdir(GITCONFIGS)) - set(linked) - set(['.git'])
+ if available:
+ print('uncloned configs:\n (clone with "gcl clone <name>...")\n')
+ for l in sorted(available):
+ print(' ' + l)
+ print()
+ for arg in args:
+ if os.path.exists(arg):
+ print(f"skipping '{arg}' because the directory already exists")
+ continue
+ if not os.path.isfile(GITCONFIGS + arg):
+ print(f"skipping '{arg}' because {GITCONFIGS}/{arg} doesn't exist")
+ continue
+ subprocess.run(['git', 'init', arg])
+ os.unlink(arg + '/.git/config')
+ os.symlink('../../' + GITCONFIGS + arg, arg + '/.git/config')
+ subprocess.run(['git', 'pull'], cwd=arg)
+
+elif cmd == 'mv':
+ if len(args) != 2:
+ print_usage()
+ src, dest = args
+ if '/' in src or '/' in dest:
+ sys.exit('fatal: src and dest may not contain slashes')
+ if src == 'gitconfigs':
+ sys.exit('fatal: cannot move gitconfigs')
+ link_error = get_link_error(src)
+ if link_error:
+ sys.exit('aborting because src is not properly linked: ' + link_error)
+ if os.path.exists(dest):
+ sys.exit(f"fatal: repo destination '{dest}' already exists")
+ if os.path.exists(GITCONFIGS + dest):
+ sys.exit(f"fatal: config destination '{GITCONFIGS + dest}' already exists")
+
+ os.rename(src, dest)
+ os.rename(GITCONFIGS + src, GITCONFIGS + dest)
+ os.unlink(dest + '/.git/config')
+ os.symlink('../../' + GITCONFIGS + dest, dest + '/.git/config')
+else:
+ print_usage()