#!/usr/bin/env python3 # git config linker (gcl) v1.0 # Written by Martin Fischer 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 ... move /.git/config to gitconfigs/ (leaving a symlink behind) gcl clone list all configs that are not cloned gcl clone ... clone a repository using gitconfigs/ gcl mv rename a repo and its config and update the symlink gcl unlink ... undo "gcl link " (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 ...")\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 ...")\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()