summaryrefslogtreecommitdiff
path: root/source_control/hg.py
blob: 89845c197c905c0b5aaff289b1aee7014878e411 (plain)
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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
#!/usr/bin/python
#-*- coding: utf-8 -*-

# (c) 2013, Yeukhon Wong <yeukhon@acm.org>
# (c) 2014, Nate Coraor <nate@bx.psu.edu>
#
# This module was originally inspired by Brad Olson's ansible-module-mercurial
# <https://github.com/bradobro/ansible-module-mercurial>. This module tends
# to follow the git module implementation.
#
# This file is part of Ansible
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.

ANSIBLE_METADATA = {'status': ['preview'],
                    'supported_by': 'community',
                    'version': '1.0'}

DOCUMENTATION = '''
---
module: hg
short_description: Manages Mercurial (hg) repositories.
description:
    - Manages Mercurial (hg) repositories. Supports SSH, HTTP/S and local address.
version_added: "1.0"
author: "Yeukhon Wong (@yeukhon)"
options:
    repo:
        description:
            - The repository address.
        required: true
        default: null
        aliases: [ name ]
    dest:
        description:
            - Absolute path of where the repository should be cloned to.
              This parameter is required, unless clone and update are set to no
        required: true
        default: null
    revision:
        description:
            - Equivalent C(-r) option in hg command which could be the changeset, revision number,
              branch name or even tag.
        required: false
        default: null
        aliases: [ version ]
    force:
        description:
            - Discards uncommitted changes. Runs C(hg update -C).  Prior to
              1.9, the default was `yes`.
        required: false
        default: "no"
        choices: [ "yes", "no" ]
    purge:
        description:
            - Deletes untracked files. Runs C(hg purge).
        required: false
        default: "no"
        choices: [ "yes", "no" ]
    update:
        required: false
        default: "yes"
        choices: [ "yes", "no" ]
        version_added: "2.0"
        description:
            - If C(no), do not retrieve new revisions from the origin repository
    clone:
        required: false
        default: "yes"
        choices: [ "yes", "no" ]
        version_added: "2.3"
        description:
            - If C(no), do not clone the repository if it does not exist locally.
    executable:
        required: false
        default: null
        version_added: "1.4"
        description:
            - Path to hg executable to use. If not supplied,
              the normal mechanism for resolving binary paths will be used.
notes:
    - "If the task seems to be hanging, first verify remote host is in C(known_hosts).
      SSH will prompt user to authorize the first contact with a remote host.  To avoid this prompt, 
      one solution is to add the remote host public key in C(/etc/ssh/ssh_known_hosts) before calling 
      the hg module, with the following command: ssh-keyscan remote_host.com >> /etc/ssh/ssh_known_hosts."
requirements: [ ]
'''

EXAMPLES = '''
# Ensure the current working copy is inside the stable branch and deletes untracked files if any.
- hg:
    repo: https://bitbucket.org/user/repo1
    dest: /home/user/repo1
    revision: stable
    purge: yes

# Example just get information about the repository whether or not it has
# already been cloned locally.
- hg:
    repo: git://bitbucket.org/user/repo
    dest: /srv/checkout
    clone: no
    update: no
'''

import os

# import module snippets
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils._text import to_native

class Hg(object):

    def __init__(self, module, dest, repo, revision, hg_path):
        self.module = module
        self.dest = dest
        self.repo = repo
        self.revision = revision
        self.hg_path = hg_path

    def _command(self, args_list):
        (rc, out, err) = self.module.run_command([self.hg_path] + args_list)
        return (rc, out, err)

    def _list_untracked(self):
        args = ['purge', '--config', 'extensions.purge=', '-R', self.dest, '--print']
        return self._command(args)

    def get_revision(self):
        """
        hg id -b -i -t returns a string in the format:
           "<changeset>[+] <branch_name> <tag>"
        This format lists the state of the current working copy,
        and indicates whether there are uncommitted changes by the
        plus sign. Otherwise, the sign is omitted.

        Read the full description via hg id --help
        """
        (rc, out, err) = self._command(['id', '-b', '-i', '-t', '-R', self.dest])
        if rc != 0:
            self.module.fail_json(msg=err)
        else:
            return to_native(out).strip('\n')

    def get_remote_revision(self):
        (rc, out, err) = self._command(['id', self.repo])
        if rc != 0:
            self.module_fail_json(msg=err)
        else:
            return to_native(out).strip('\n')

    def has_local_mods(self):
        now = self.get_revision()
        if '+' in now:
            return True
        else:
            return False

    def discard(self):
        before = self.has_local_mods()
        if not before:
            return False

        args = ['update', '-C', '-R', self.dest, '-r', '.']
        (rc, out, err) = self._command(args)
        if rc != 0:
            self.module.fail_json(msg=err)

        after = self.has_local_mods()
        if before != after and not after:   # no more local modification
            return True

    def purge(self):
        # before purge, find out if there are any untracked files
        (rc1, out1, err1) = self._list_untracked()
        if rc1 != 0:
            self.module.fail_json(msg=err1)

        # there are some untrackd files
        if out1 != '':
            args = ['purge', '--config', 'extensions.purge=', '-R', self.dest]
            (rc2, out2, err2) = self._command(args)
            if rc2 != 0:
                self.module.fail_json(msg=err2)
            return True
        else:
            return False

    def cleanup(self, force, purge):
        discarded = False
        purged = False

        if force:
            discarded = self.discard()
        if purge:
            purged = self.purge()
        if discarded or purged:
            return True
        else:
            return False

    def pull(self):
        return self._command(
            ['pull', '-R', self.dest, self.repo])

    def update(self):
        if self.revision is not None:
            return self._command(['update', '-r', self.revision, '-R', self.dest])
        return self._command(['update', '-R', self.dest])

    def clone(self):
        if self.revision is not None:
            return self._command(['clone', self.repo, self.dest, '-r', self.revision])
        return self._command(['clone', self.repo, self.dest])

    @property
    def at_revision(self):
        """
        There is no point in pulling from a potentially down/slow remote site
        if the desired changeset is already the current changeset.
        """
        if self.revision is None or len(self.revision) < 7:
            # Assume it's a rev number, tag, or branch
            return False
        (rc, out, err) = self._command(['--debug', 'id', '-i', '-R', self.dest])
        if rc != 0:
            self.module.fail_json(msg=err)
        if out.startswith(self.revision):
            return True
        return False

# ===========================================

def main():
    module = AnsibleModule(
        argument_spec = dict(
            repo = dict(required=True, aliases=['name']),
            dest = dict(type='path'),
            revision = dict(default=None, aliases=['version']),
            force = dict(default='no', type='bool'),
            purge = dict(default='no', type='bool'),
            update = dict(default='yes', type='bool'),
            clone = dict(default='yes', type='bool'),
            executable = dict(default=None),
        ),
    )
    repo = module.params['repo']
    dest = module.params['dest']
    revision = module.params['revision']
    force = module.params['force']
    purge = module.params['purge']
    update = module.params['update']
    clone = module.params['clone']
    hg_path = module.params['executable'] or module.get_bin_path('hg', True)
    if dest is not None:
        hgrc = os.path.join(dest, '.hg/hgrc')

    # initial states
    before = ''
    changed = False
    cleaned = False

    if not dest and (clone or update):
        module.fail_json(msg="the destination directory must be specified unless clone=no and update=no")

    hg = Hg(module, dest, repo, revision, hg_path)

    # If there is no hgrc file, then assume repo is absent
    # and perform clone. Otherwise, perform pull and update.
    if not clone and not update:
        out = hg.get_remote_revision()
        module.exit_json(after=out, changed=False)
    if not os.path.exists(hgrc):
        if clone:
            (rc, out, err) = hg.clone()
            if rc != 0:
                module.fail_json(msg=err)
        else:
            module.exit_json(changed=False)
    elif not update:
        # Just return having found a repo already in the dest path
        before = hg.get_revision()
    elif hg.at_revision:
        # no update needed, don't pull
        before = hg.get_revision()

        # but force and purge if desired
        cleaned = hg.cleanup(force, purge)
    else:
        # get the current state before doing pulling
        before = hg.get_revision()

        # can perform force and purge
        cleaned = hg.cleanup(force, purge)

        (rc, out, err) = hg.pull()
        if rc != 0:
            module.fail_json(msg=err)

        (rc, out, err) = hg.update()
        if rc != 0:
            module.fail_json(msg=err)

    after = hg.get_revision()
    if before != after or cleaned:
        changed = True
    module.exit_json(before=before, after=after, changed=changed, cleaned=cleaned)

if __name__ == '__main__':
    main()