add support for github -> local sync
[git-mirror.git] / update.py
1 #!/usr/bin/python3
2 import sys, os, subprocess, argparse
3 import configparser, itertools, json, re
4 import traceback
5 import email.mime.text, email.utils, smtplib
6
7 class GitCommand:
8     def __getattr__(self, name):
9         def call(*args, capture_stderr = False, check = True):
10             '''If <capture_stderr>, return stderr merged with stdout. Otherwise, return stdout and forward stderr to our own.
11                If <check> is true, throw an exception of the process fails with non-zero exit code. Otherwise, do not.
12                In any case, return a pair of the captured output and the exit code.'''
13             cmd = ["git", name.replace('_', '-')] + list(args)
14             with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT if capture_stderr else None) as p:
15                 (stdout, stderr) = p.communicate()
16                 assert stderr is None
17                 code = p.returncode
18                 if check and code:
19                     raise Exception("Error running {0}: Non-zero exit code".format(cmd))
20             return (stdout.decode('utf-8').strip('\n'), code)
21         return call
22
23 git = GitCommand()
24
25 def read_config(fname, defSection = 'DEFAULT'):
26     '''Reads a config file that may have options outside of any section.'''
27     config = configparser.ConfigParser()
28     with open(fname) as file:
29         stream = itertools.chain(("["+defSection+"]\n",), file)
30         config.read_file(stream)
31     return config
32
33 def send_mail(subject, text, receivers, sender='post+webhook@ralfj.de', replyTo=None):
34     assert isinstance(receivers, list)
35     if not len(receivers): return # nothing to do
36     # construct content
37     msg = email.mime.text.MIMEText(text.encode('UTF-8'), 'plain', 'UTF-8')
38     msg['Subject'] = subject
39     msg['Date'] = email.utils.formatdate(localtime=True)
40     msg['From'] = sender
41     msg['To'] = ', '.join(receivers)
42     if replyTo is not None:
43         msg['Reply-To'] = replyTo
44     # put into envelope and send
45     s = smtplib.SMTP('localhost')
46     s.sendmail(sender, receivers, msg.as_string())
47     s.quit()
48
49 def get_github_payload():
50     '''Reeturn the github-style JSON encoded payload (as if we were called as a github webhook)'''
51     try:
52         data = sys.stdin.buffer.read()
53         data = json.loads(data.decode('utf-8'))
54         return data
55     except:
56         return {} # nothing read
57
58 class Repo:
59     def __init__(self, conf):
60         '''Creates a repository from a section of the git-mirror configuration file'''
61         self.local = conf['local']
62         self.mirrors = {} # maps mirrors to their URLs
63         mirror_prefix = 'mirror-'
64         for name in filter(lambda s: s.startswith(mirror_prefix), conf.keys()):
65             mirror = name[len(mirror_prefix):]
66             self.mirrors[mirror] = conf[name]
67     
68     def find_mirror_by_url(self, match_urls):
69         for mirror, url in self.mirrors.items():
70             if url in match_urls:
71                 return mirror
72         return None
73
74     def have_ref(self, ref, url=None):
75         '''Tests if a given ref exists, locally or (if the url is given) remotely'''
76         if url is None:
77             out, code = git.show_ref(ref, check = False)
78             if code and len(out):
79                 raise Exception("Checking for a local ref failed")
80         else:
81             out, code = git.ls_remote(url, ref)
82         # the ref exists iff we have output
83         return len(out) > 0
84     
85     def update_mirrors(self, ref, delete, exception = None, suppress_stderr = False):
86         '''Update <ref> on all mirrors except for <exception> to the local state, or delete it.'''
87         for mirror in self.mirrors:
88             if mirror == exception:
89                 continue
90             # update this mirror
91             if not self.have_ref(ref):
92                 # delete ref remotely
93                 git.push(self.mirrors[mirror], ':'+ref, capture_stderr = suppress_stderr)
94             else:
95                 # update ref remotely
96                 git.push('--force', self.mirrors[mirror], ref, capture_stderr = suppress_stderr)
97     
98     def update_ref(self, ref, source, suppress_stderr = False):
99         '''Update the <ref> to its state in <source> everywhere. <source> is None to refer to the local repository,
100            or the name of a mirror.'''
101         os.chdir(self.local)
102         if source is None:
103             # We already have the latest version locally. Update all the mirrors.
104             self.update_mirrors(ref, delete = not self.have_ref(ref), suppress_stderr = suppress_stderr)
105         else:
106             # update our version of this ref. This may fail if the ref does not exist anymore.
107             url = self.mirrors[source]
108             if not self.have_ref(ref, url):
109                 # delete ref locally
110                 git.update_ref("-d", ref)
111                 # and everywhere (except for the source)
112                 self.update_mirrors(ref, delete = True, exception = source, suppress_stderr = suppress_stderr)
113             else:
114                 # update local ref to remote state (yes, there's a race condition here - the ref could no longer exist by now)
115                 git.fetch(url, ref+":"+ref)
116                 # and everywhere else
117                 self.update_mirrors(ref, delete = False, exception = source, suppress_stderr = suppress_stderr)
118
119 def find_repo_by_directory(repos, dir):
120     for (name, repo) in repos.items():
121         if dir == repo.local:
122             return name
123     return None
124
125 if __name__ == "__main__":
126     parser = argparse.ArgumentParser(description='Keep git repositories in sync')
127     parser.add_argument("--git-hook",
128                         action="store_true", dest="git_hook",
129                         help="Act as git hook: Auto-detect the repository based on the working directoy, and fetch information from stdin the way git encodes it")
130     parser.add_argument("--web-hook",
131                         action="store_true", dest="web_hook",
132                         help="Act as github-style web hook: Repository has to be given explicitly, all the rest is read from stdin JSON form")
133     parser.add_argument("-r", "--repository",
134                         dest="repository",
135                         help="The name of the repository to act on")
136     args = parser.parse_args()
137     if args.git_hook and args.web_hook:
138         raise Exception("I cannot be two hooks at once.")
139     
140     try:
141         # All arguments are *untrusted* input, as we may be called via sudo from the webserver. So we fix the configuration file location.
142         conffile = os.path.join(os.path.dirname(__file__), 'git-mirror.conf')
143         conf = read_config(conffile)
144         repos = {}
145         for name, section in conf.items():
146             if name != 'DEFAULT':
147                 repos[name] = Repo(section)
148         
149         # find the repository we are dealing with
150         reponame = args.repository
151         if reponame is None and args.git_hook:
152             reponame = find_repo_by_directory(repos, os.getcwd())
153         if reponame is None or reponame not in repos:
154             raise Exception("Unknown or missing repository name.")
155         
156         # now sync this repository
157         repo = repos[reponame]
158         if args.git_hook:
159             # parse the information we get from stdin
160             for line in sys.stdin:
161                 (oldsha, newsha, ref) = line.split()
162                 repo.update_ref(ref, source = None)
163         elif args.web_hook:
164             data = get_github_payload()
165             ref = data["ref"]
166             # validate the ref name
167             if re.match('refs/[a-z/]+', ref) is None:
168                 raise Exception("Invalid ref name {0}".format(ref))
169             # collect URLs of this repository
170             urls = []
171             for key in ("git_url", "ssh_url", "clone_url"):
172                 urls.append(data["repository"][key])
173             source = repo.find_mirror_by_url(urls)
174             if source is None:
175                 raise Exception("Could not find the source.")
176             repo.update_ref(ref, source = source, suppress_stderr = True)
177             # print an answer
178             print("Content-Type: text/plain")
179             print()
180             print("Updated {0}:{1} from source {2}".format(reponame, ref, source))
181         else:
182             raise Exception("No manual mode is implemented so far.")
183     except Exception as e:
184         # don't leak filenames etc. when we are running as a hook
185         if args.web_hook:
186             print("Status: 500 Internal Server Error")
187             print("Content-Type: text/plain")
188             print()
189             print(str(e))
190         elif args.git_hook:
191             #sys.stderr.write(str(e))
192             traceback.print_exc()
193         else:
194             traceback.print_exc()