##// END OF EJS Templates
git: skeleton of a new extension to _directly_ operate on git repos...
Augie Fackler -
r44961:ad718271 default
parent child Browse files
Show More
@@ -0,0 +1,39 b''
1 Octopus Merge Support
2 =====================
3
4 This will be moderately complicated, as we'll need to synthesize phony
5 changeset entries to explode the octopus into "revisions" that only
6 have two parents each. For today, we can probably just do something like
7
8 aaaaaaaaaaaaaaaaaaXX{20 bytes of exploded node's hex sha}
9
10 where XX is a counter (so we could have as many as 255 parents in a
11 git commit - more than I think we'd ever see.) That means that we can
12 install some check in this extension to disallow checking out or
13 otherwise interacting with the `aaaaaaaaaaaaaaaaaa` revisions.
14
15
16 Interface Creation
17 ====================
18
19 We at least need an interface definition for `changelog` in core that
20 this extension can satisfy, and again for `basicstore`.
21
22
23 Reason About Locking
24 ====================
25
26 We should spend some time thinking hard about locking, especially on
27 .git/index etc. We're probably adequately locking the _git_
28 repository, but may not have enough locking correctness in places
29 where hg does locking that git isn't aware of (notably the working
30 copy, which I believe Git does not lock.)
31
32 Clean up requirements
33 =====================
34
35 Right now (for historical reasons, mainly) hgext.git uses a
36 .hg/this-is-git file to detect repositories that should be treated as
37 git. We should look in the .hg/requires for the "git" requirement
38 instead (we already set this requirement, so it's mostly keying off
39 that instead of using an empty file.)
@@ -0,0 +1,259 b''
1 """grant Mercurial the ability to operate on Git repositories. (EXPERIMENTAL)
2
3 This is currently super experimental. It probably will consume your
4 firstborn a la Rumpelstiltskin, etc.
5 """
6
7 from __future__ import absolute_import
8
9 import os
10
11 import pygit2
12
13 from mercurial.i18n import _
14
15 from mercurial import (
16 commands,
17 error,
18 extensions,
19 localrepo,
20 pycompat,
21 store,
22 util,
23 )
24
25 from . import (
26 dirstate,
27 gitlog,
28 gitutil,
29 index,
30 )
31
32
33 # TODO: extract an interface for this in core
34 class gitstore(object): # store.basicstore):
35 def __init__(self, path, vfstype):
36 self.vfs = vfstype(path)
37 self.path = self.vfs.base
38 self.createmode = store._calcmode(self.vfs)
39 # above lines should go away in favor of:
40 # super(gitstore, self).__init__(path, vfstype)
41
42 self.git = pygit2.Repository(
43 os.path.normpath(os.path.join(path, b'..', b'.git'))
44 )
45 self._progress_factory = lambda *args, **kwargs: None
46
47 @util.propertycache
48 def _db(self):
49 # We lazy-create the database because we want to thread a
50 # progress callback down to the indexing process if it's
51 # required, and we don't have a ui handle in makestore().
52 return index.get_index(self.git, self._progress_factory)
53
54 def join(self, f):
55 """Fake store.join method for git repositories.
56
57 For the most part, store.join is used for @storecache
58 decorators to invalidate caches when various files
59 change. We'll map the ones we care about, and ignore the rest.
60 """
61 if f in (b'00changelog.i', b'00manifest.i'):
62 # This is close enough: in order for the changelog cache
63 # to be invalidated, HEAD will have to change.
64 return os.path.join(self.path, b'HEAD')
65 elif f == b'lock':
66 # TODO: we probably want to map this to a git lock, I
67 # suspect index.lock. We should figure out what the
68 # most-alike file is in git-land. For now we're risking
69 # bad concurrency errors if another git client is used.
70 return os.path.join(self.path, b'hgit-bogus-lock')
71 elif f in (b'obsstore', b'phaseroots', b'narrowspec', b'bookmarks'):
72 return os.path.join(self.path, b'..', b'.hg', f)
73 raise NotImplementedError(b'Need to pick file for %s.' % f)
74
75 def changelog(self, trypending):
76 # TODO we don't have a plan for trypending in hg's git support yet
77 return gitlog.changelog(self.git, self._db)
78
79 def manifestlog(self, repo, storenarrowmatch):
80 # TODO handle storenarrowmatch and figure out if we need the repo arg
81 return gitlog.manifestlog(self.git, self._db)
82
83 def invalidatecaches(self):
84 pass
85
86 def write(self, tr=None):
87 # normally this handles things like fncache writes, which we don't have
88 pass
89
90
91 def _makestore(orig, requirements, storebasepath, vfstype):
92 if os.path.exists(
93 os.path.join(storebasepath, b'this-is-git')
94 ) and os.path.exists(os.path.join(storebasepath, b'..', b'.git')):
95 return gitstore(storebasepath, vfstype)
96 return orig(requirements, storebasepath, vfstype)
97
98
99 class gitfilestorage(object):
100 def file(self, path):
101 if path[0:1] == b'/':
102 path = path[1:]
103 return gitlog.filelog(self.store.git, self.store._db, path)
104
105
106 def _makefilestorage(orig, requirements, features, **kwargs):
107 store = kwargs['store']
108 if isinstance(store, gitstore):
109 return gitfilestorage
110 return orig(requirements, features, **kwargs)
111
112
113 def _setupdothg(ui, path):
114 dothg = os.path.join(path, b'.hg')
115 if os.path.exists(dothg):
116 ui.warn(_(b'git repo already initialized for hg\n'))
117 else:
118 os.mkdir(os.path.join(path, b'.hg'))
119 # TODO is it ok to extend .git/info/exclude like this?
120 with open(
121 os.path.join(path, b'.git', b'info', b'exclude'), 'ab'
122 ) as exclude:
123 exclude.write(b'\n.hg\n')
124 with open(os.path.join(dothg, b'this-is-git'), 'wb') as f:
125 pass
126 with open(os.path.join(dothg, b'requirements'), 'wb') as f:
127 f.write(b'git\n')
128
129
130 _BMS_PREFIX = 'refs/heads/'
131
132
133 class gitbmstore(object):
134 def __init__(self, gitrepo):
135 self.gitrepo = gitrepo
136
137 def __contains__(self, name):
138 return (
139 _BMS_PREFIX + pycompat.fsdecode(name)
140 ) in self.gitrepo.references
141
142 def __iter__(self):
143 for r in self.gitrepo.listall_references():
144 if r.startswith(_BMS_PREFIX):
145 yield pycompat.fsencode(r[len(_BMS_PREFIX) :])
146
147 def __getitem__(self, k):
148 return (
149 self.gitrepo.references[_BMS_PREFIX + pycompat.fsdecode(k)]
150 .peel()
151 .id.raw
152 )
153
154 def get(self, k, default=None):
155 try:
156 if k in self:
157 return self[k]
158 return default
159 except pygit2.InvalidSpecError:
160 return default
161
162 @property
163 def active(self):
164 h = self.gitrepo.references['HEAD']
165 if not isinstance(h.target, str) or not h.target.startswith(
166 _BMS_PREFIX
167 ):
168 return None
169 return pycompat.fsencode(h.target[len(_BMS_PREFIX) :])
170
171 @active.setter
172 def active(self, mark):
173 raise NotImplementedError
174
175 def names(self, node):
176 r = []
177 for ref in self.gitrepo.listall_references():
178 if not ref.startswith(_BMS_PREFIX):
179 continue
180 if self.gitrepo.references[ref].peel().id.raw != node:
181 continue
182 r.append(pycompat.fsencode(ref[len(_BMS_PREFIX) :]))
183 return r
184
185 # Cleanup opportunity: this is *identical* to core's bookmarks store.
186 def expandname(self, bname):
187 if bname == b'.':
188 if self.active:
189 return self.active
190 raise error.RepoLookupError(_(b"no active bookmark"))
191 return bname
192
193 def applychanges(self, repo, tr, changes):
194 """Apply a list of changes to bookmarks
195 """
196 # TODO: this should respect transactions, but that's going to
197 # require enlarging the gitbmstore to know how to do in-memory
198 # temporary writes and read those back prior to transaction
199 # finalization.
200 for name, node in changes:
201 if node is None:
202 self.gitrepo.references.delete(
203 _BMS_PREFIX + pycompat.fsdecode(name)
204 )
205 else:
206 self.gitrepo.references.create(
207 _BMS_PREFIX + pycompat.fsdecode(name),
208 gitutil.togitnode(node),
209 force=True,
210 )
211
212
213 def init(orig, ui, dest=b'.', **opts):
214 if opts.get('git', False):
215 path = os.path.abspath(dest)
216 # TODO: walk up looking for the git repo
217 _setupdothg(ui, path)
218 return 0
219 return orig(ui, dest=dest, **opts)
220
221
222 def reposetup(ui, repo):
223 if isinstance(repo.store, gitstore):
224 orig = repo.__class__
225 repo.store._progress_factory = repo.ui.makeprogress
226
227 class gitlocalrepo(orig):
228 def _makedirstate(self):
229 # TODO narrow support here
230 return dirstate.gitdirstate(
231 self.ui, self.vfs.base, self.store.git
232 )
233
234 def commit(self, *args, **kwargs):
235 ret = orig.commit(self, *args, **kwargs)
236 tid = self.store.git[gitutil.togitnode(ret)].tree.id
237 # DANGER! This will flush any writes staged to the
238 # index in Git, but we're sidestepping the index in a
239 # way that confuses git when we commit. Alas.
240 self.store.git.index.read_tree(tid)
241 self.store.git.index.write()
242 return ret
243
244 @property
245 def _bookmarks(self):
246 return gitbmstore(self.store.git)
247
248 repo.__class__ = gitlocalrepo
249 return repo
250
251
252 def extsetup(ui):
253 extensions.wrapfunction(localrepo, b'makestore', _makestore)
254 extensions.wrapfunction(localrepo, b'makefilestorage', _makefilestorage)
255 # Inject --git flag for `hg init`
256 entry = extensions.wrapcommand(commands.table, b'init', init)
257 entry[1].extend(
258 [(b'', b'git', None, b'setup up a git repository instead of hg')]
259 )
@@ -0,0 +1,295 b''
1 from __future__ import absolute_import
2
3 import contextlib
4 import errno
5 import os
6
7 import pygit2
8
9 from mercurial import (
10 error,
11 extensions,
12 match as matchmod,
13 node as nodemod,
14 pycompat,
15 scmutil,
16 util,
17 )
18 from mercurial.interfaces import (
19 dirstate as intdirstate,
20 util as interfaceutil,
21 )
22
23 from . import gitutil
24
25
26 def readpatternfile(orig, filepath, warn, sourceinfo=False):
27 if not (b'info/exclude' in filepath or filepath.endswith(b'.gitignore')):
28 return orig(filepath, warn, sourceinfo=False)
29 result = []
30 warnings = []
31 with open(filepath, b'rb') as fp:
32 for l in fp:
33 l = l.strip()
34 if not l or l.startswith(b'#'):
35 continue
36 if l.startswith(b'!'):
37 warnings.append(b'unsupported ignore pattern %s' % l)
38 continue
39 if l.startswith(b'/'):
40 result.append(b'rootglob:' + l[1:])
41 else:
42 result.append(b'relglob:' + l)
43 return result, warnings
44
45
46 extensions.wrapfunction(matchmod, b'readpatternfile', readpatternfile)
47
48
49 _STATUS_MAP = {
50 pygit2.GIT_STATUS_CONFLICTED: b'm',
51 pygit2.GIT_STATUS_CURRENT: b'n',
52 pygit2.GIT_STATUS_IGNORED: b'?',
53 pygit2.GIT_STATUS_INDEX_DELETED: b'r',
54 pygit2.GIT_STATUS_INDEX_MODIFIED: b'n',
55 pygit2.GIT_STATUS_INDEX_NEW: b'a',
56 pygit2.GIT_STATUS_INDEX_RENAMED: b'a',
57 pygit2.GIT_STATUS_INDEX_TYPECHANGE: b'n',
58 pygit2.GIT_STATUS_WT_DELETED: b'r',
59 pygit2.GIT_STATUS_WT_MODIFIED: b'n',
60 pygit2.GIT_STATUS_WT_NEW: b'?',
61 pygit2.GIT_STATUS_WT_RENAMED: b'a',
62 pygit2.GIT_STATUS_WT_TYPECHANGE: b'n',
63 pygit2.GIT_STATUS_WT_UNREADABLE: b'?',
64 pygit2.GIT_STATUS_INDEX_MODIFIED | pygit2.GIT_STATUS_WT_MODIFIED: 'm',
65 }
66
67
68 @interfaceutil.implementer(intdirstate.idirstate)
69 class gitdirstate(object):
70 def __init__(self, ui, root, gitrepo):
71 self._ui = ui
72 self._root = os.path.dirname(root)
73 self.git = gitrepo
74 self._plchangecallbacks = {}
75
76 def p1(self):
77 return self.git.head.peel().id.raw
78
79 def p2(self):
80 # TODO: MERGE_HEAD? something like that, right?
81 return nodemod.nullid
82
83 def setparents(self, p1, p2=nodemod.nullid):
84 assert p2 == nodemod.nullid, b'TODO merging support'
85 self.git.head.set_target(gitutil.togitnode(p1))
86
87 @util.propertycache
88 def identity(self):
89 return util.filestat.frompath(
90 os.path.join(self._root, b'.git', b'index')
91 )
92
93 def branch(self):
94 return b'default'
95
96 def parents(self):
97 # TODO how on earth do we find p2 if a merge is in flight?
98 return self.p1(), nodemod.nullid
99
100 def __iter__(self):
101 return (pycompat.fsencode(f.path) for f in self.git.index)
102
103 def items(self):
104 for ie in self.git.index:
105 yield ie.path, None # value should be a dirstatetuple
106
107 # py2,3 compat forward
108 iteritems = items
109
110 def __getitem__(self, filename):
111 try:
112 gs = self.git.status_file(filename)
113 except KeyError:
114 return b'?'
115 return _STATUS_MAP[gs]
116
117 def __contains__(self, filename):
118 try:
119 gs = self.git.status_file(filename)
120 return _STATUS_MAP[gs] != b'?'
121 except KeyError:
122 return False
123
124 def status(self, match, subrepos, ignored, clean, unknown):
125 # TODO handling of clean files - can we get that from git.status()?
126 modified, added, removed, deleted, unknown, ignored, clean = (
127 [],
128 [],
129 [],
130 [],
131 [],
132 [],
133 [],
134 )
135 gstatus = self.git.status()
136 for path, status in gstatus.items():
137 path = pycompat.fsencode(path)
138 if status == pygit2.GIT_STATUS_IGNORED:
139 if path.endswith(b'/'):
140 continue
141 ignored.append(path)
142 elif status in (
143 pygit2.GIT_STATUS_WT_MODIFIED,
144 pygit2.GIT_STATUS_INDEX_MODIFIED,
145 pygit2.GIT_STATUS_WT_MODIFIED
146 | pygit2.GIT_STATUS_INDEX_MODIFIED,
147 ):
148 modified.append(path)
149 elif status == pygit2.GIT_STATUS_INDEX_NEW:
150 added.append(path)
151 elif status == pygit2.GIT_STATUS_WT_NEW:
152 unknown.append(path)
153 elif status == pygit2.GIT_STATUS_WT_DELETED:
154 deleted.append(path)
155 elif status == pygit2.GIT_STATUS_INDEX_DELETED:
156 removed.append(path)
157 else:
158 raise error.Abort(
159 b'unhandled case: status for %r is %r' % (path, status)
160 )
161
162 # TODO are we really always sure of status here?
163 return (
164 False,
165 scmutil.status(
166 modified, added, removed, deleted, unknown, ignored, clean
167 ),
168 )
169
170 def flagfunc(self, buildfallback):
171 # TODO we can do better
172 return buildfallback()
173
174 def getcwd(self):
175 # TODO is this a good way to do this?
176 return os.path.dirname(
177 os.path.dirname(pycompat.fsencode(self.git.path))
178 )
179
180 def normalize(self, path):
181 normed = util.normcase(path)
182 assert normed == path, b"TODO handling of case folding: %s != %s" % (
183 normed,
184 path,
185 )
186 return path
187
188 @property
189 def _checklink(self):
190 return util.checklink(os.path.dirname(pycompat.fsencode(self.git.path)))
191
192 def copies(self):
193 # TODO support copies?
194 return {}
195
196 # # TODO what the heck is this
197 _filecache = set()
198
199 def pendingparentchange(self):
200 # TODO: we need to implement the context manager bits and
201 # correctly stage/revert index edits.
202 return False
203
204 def write(self, tr):
205 # TODO: call parent change callbacks
206
207 if tr:
208
209 def writeinner(category):
210 self.git.index.write()
211
212 tr.addpending(b'gitdirstate', writeinner)
213 else:
214 self.git.index.write()
215
216 def pathto(self, f, cwd=None):
217 if cwd is None:
218 cwd = self.getcwd()
219 # TODO core dirstate does something about slashes here
220 assert isinstance(f, bytes)
221 r = util.pathto(self._root, cwd, f)
222 return r
223
224 def matches(self, match):
225 for x in self.git.index:
226 p = pycompat.fsencode(x.path)
227 if match(p):
228 yield p
229
230 def normal(self, f, parentfiledata=None):
231 """Mark a file normal and clean."""
232 # TODO: for now we just let libgit2 re-stat the file. We can
233 # clearly do better.
234
235 def normallookup(self, f):
236 """Mark a file normal, but possibly dirty."""
237 # TODO: for now we just let libgit2 re-stat the file. We can
238 # clearly do better.
239
240 def walk(self, match, subrepos, unknown, ignored, full=True):
241 # TODO: we need to use .status() and not iterate the index,
242 # because the index doesn't force a re-walk and so `hg add` of
243 # a new file without an intervening call to status will
244 # silently do nothing.
245 r = {}
246 cwd = self.getcwd()
247 for path, status in self.git.status().items():
248 if path.startswith('.hg/'):
249 continue
250 path = pycompat.fsencode(path)
251 if not match(path):
252 continue
253 # TODO construct the stat info from the status object?
254 try:
255 s = os.stat(os.path.join(cwd, path))
256 except OSError as e:
257 if e.errno != errno.ENOENT:
258 raise
259 continue
260 r[path] = s
261 return r
262
263 def savebackup(self, tr, backupname):
264 # TODO: figure out a strategy for saving index backups.
265 pass
266
267 def restorebackup(self, tr, backupname):
268 # TODO: figure out a strategy for saving index backups.
269 pass
270
271 def add(self, f):
272 self.git.index.add(pycompat.fsdecode(f))
273
274 def drop(self, f):
275 self.git.index.remove(pycompat.fsdecode(f))
276
277 def remove(self, f):
278 self.git.index.remove(pycompat.fsdecode(f))
279
280 def copied(self, path):
281 # TODO: track copies?
282 return None
283
284 @contextlib.contextmanager
285 def parentchange(self):
286 # TODO: track this maybe?
287 yield
288
289 def addparentchangecallback(self, category, callback):
290 # TODO: should this be added to the dirstate interface?
291 self._plchangecallbacks[category] = callback
292
293 def clearbackup(self, tr, backupname):
294 # TODO
295 pass
@@ -0,0 +1,463 b''
1 from __future__ import absolute_import
2
3 import pygit2
4
5 from mercurial.i18n import _
6
7 from mercurial import (
8 ancestor,
9 changelog as hgchangelog,
10 dagop,
11 encoding,
12 error,
13 manifest,
14 node as nodemod,
15 pycompat,
16 )
17 from mercurial.interfaces import (
18 repository,
19 util as interfaceutil,
20 )
21 from mercurial.utils import stringutil
22 from . import (
23 gitutil,
24 index,
25 manifest as gitmanifest,
26 )
27
28
29 class baselog(object): # revlog.revlog):
30 """Common implementations between changelog and manifestlog."""
31
32 def __init__(self, gr, db):
33 self.gitrepo = gr
34 self._db = db
35
36 def __len__(self):
37 return int(
38 self._db.execute('SELECT COUNT(*) FROM changelog').fetchone()[0]
39 )
40
41 def rev(self, n):
42 if n == nodemod.nullid:
43 return -1
44 t = self._db.execute(
45 'SELECT rev FROM changelog WHERE node = ?', (gitutil.togitnode(n),)
46 ).fetchone()
47 if t is None:
48 raise error.LookupError(n, b'00changelog.i', _(b'no node %d'))
49 return t[0]
50
51 def node(self, r):
52 if r == nodemod.nullrev:
53 return nodemod.nullid
54 t = self._db.execute(
55 'SELECT node FROM changelog WHERE rev = ?', (r,)
56 ).fetchone()
57 if t is None:
58 raise error.LookupError(r, b'00changelog.i', _(b'no node'))
59 return nodemod.bin(t[0])
60
61 def hasnode(self, n):
62 t = self._db.execute(
63 'SELECT node FROM changelog WHERE node = ?', (n,)
64 ).fetchone()
65 return t is not None
66
67
68 class baselogindex(object):
69 def __init__(self, log):
70 self._log = log
71
72 def has_node(self, n):
73 return self._log.rev(n) != -1
74
75 def __len__(self):
76 return len(self._log)
77
78 def __getitem__(self, idx):
79 p1rev, p2rev = self._log.parentrevs(idx)
80 # TODO: it's messy that the index leaks so far out of the
81 # storage layer that we have to implement things like reading
82 # this raw tuple, which exposes revlog internals.
83 return (
84 # Pretend offset is just the index, since we don't really care.
85 idx,
86 # Same with lengths
87 idx, # length
88 idx, # rawsize
89 -1, # delta base
90 idx, # linkrev TODO is this right?
91 p1rev,
92 p2rev,
93 self._log.node(idx),
94 )
95
96
97 # TODO: an interface for the changelog type?
98 class changelog(baselog):
99 def __contains__(self, rev):
100 try:
101 self.node(rev)
102 return True
103 except error.LookupError:
104 return False
105
106 @property
107 def filteredrevs(self):
108 # TODO: we should probably add a refs/hg/ namespace for hidden
109 # heads etc, but that's an idea for later.
110 return set()
111
112 @property
113 def index(self):
114 return baselogindex(self)
115
116 @property
117 def nodemap(self):
118 r = {
119 nodemod.bin(v[0]): v[1]
120 for v in self._db.execute('SELECT node, rev FROM changelog')
121 }
122 r[nodemod.nullid] = nodemod.nullrev
123 return r
124
125 def tip(self):
126 t = self._db.execute(
127 'SELECT node FROM changelog ORDER BY rev DESC LIMIT 1'
128 ).fetchone()
129 if t:
130 return nodemod.bin(t[0])
131 return nodemod.nullid
132
133 def revs(self, start=0, stop=None):
134 if stop is None:
135 stop = self.tip()
136 t = self._db.execute(
137 'SELECT rev FROM changelog '
138 'WHERE rev >= ? AND rev <= ? '
139 'ORDER BY REV ASC',
140 (start, stop),
141 )
142 return (int(r[0]) for r in t)
143
144 def _partialmatch(self, id):
145 if nodemod.wdirhex.startswith(id):
146 raise error.WdirUnsupported
147 candidates = [
148 nodemod.bin(x[0])
149 for x in self._db.execute(
150 'SELECT node FROM changelog WHERE node LIKE ?', (id + b'%',)
151 )
152 ]
153 if nodemod.nullhex.startswith(id):
154 candidates.append(nodemod.nullid)
155 if len(candidates) > 1:
156 raise error.AmbiguousPrefixLookupError(
157 id, b'00changelog.i', _(b'ambiguous identifier')
158 )
159 if candidates:
160 return candidates[0]
161 return None
162
163 def flags(self, rev):
164 return 0
165
166 def shortest(self, node, minlength=1):
167 nodehex = nodemod.hex(node)
168 for attempt in pycompat.xrange(minlength, len(nodehex) + 1):
169 candidate = nodehex[:attempt]
170 matches = int(
171 self._db.execute(
172 'SELECT COUNT(*) FROM changelog WHERE node LIKE ?',
173 (pycompat.sysstr(nodehex + b'%'),),
174 ).fetchone()[0]
175 )
176 if matches == 1:
177 return candidate
178 return nodehex
179
180 def headrevs(self, revs=None):
181 realheads = [
182 int(x[0])
183 for x in self._db.execute(
184 'SELECT rev FROM changelog '
185 'INNER JOIN heads ON changelog.node = heads.node'
186 )
187 ]
188 if revs:
189 return sorted([r for r in revs if r in realheads])
190 return sorted(realheads)
191
192 def changelogrevision(self, nodeorrev):
193 # Ensure we have a node id
194 if isinstance(nodeorrev, int):
195 n = self.node(nodeorrev)
196 else:
197 n = nodeorrev
198 # handle looking up nullid
199 if n == nodemod.nullid:
200 return hgchangelog._changelogrevision(extra={})
201 hn = gitutil.togitnode(n)
202 # We've got a real commit!
203 files = [
204 r[0]
205 for r in self._db.execute(
206 'SELECT filename FROM changedfiles '
207 'WHERE node = ? and filenode != ?',
208 (hn, gitutil.nullgit),
209 )
210 ]
211 filesremoved = [
212 r[0]
213 for r in self._db.execute(
214 'SELECT filename FROM changedfiles '
215 'WHERE node = ? and filenode = ?',
216 (hn, nodemod.nullhex),
217 )
218 ]
219 c = self.gitrepo[hn]
220 return hgchangelog._changelogrevision(
221 manifest=n, # pretend manifest the same as the commit node
222 user=b'%s <%s>'
223 % (c.author.name.encode('utf8'), c.author.email.encode('utf8')),
224 date=(c.author.time, -c.author.offset * 60),
225 files=files,
226 # TODO filesadded in the index
227 filesremoved=filesremoved,
228 description=c.message.encode('utf8'),
229 # TODO do we want to handle extra? how?
230 extra={b'branch': b'default'},
231 )
232
233 def ancestors(self, revs, stoprev=0, inclusive=False):
234 revs = list(revs)
235 tip = self.rev(self.tip())
236 for r in revs:
237 if r > tip:
238 raise IndexError(b'Invalid rev %r' % r)
239 return ancestor.lazyancestors(
240 self.parentrevs, revs, stoprev=stoprev, inclusive=inclusive
241 )
242
243 # Cleanup opportunity: this is *identical* to the revlog.py version
244 def descendants(self, revs):
245 return dagop.descendantrevs(revs, self.revs, self.parentrevs)
246
247 def reachableroots(self, minroot, heads, roots, includepath=False):
248 return dagop._reachablerootspure(
249 self.parentrevs, minroot, roots, heads, includepath
250 )
251
252 # Cleanup opportunity: this is *identical* to the revlog.py version
253 def isancestor(self, a, b):
254 a, b = self.rev(a), self.rev(b)
255 return self.isancestorrev(a, b)
256
257 # Cleanup opportunity: this is *identical* to the revlog.py version
258 def isancestorrev(self, a, b):
259 if a == nodemod.nullrev:
260 return True
261 elif a == b:
262 return True
263 elif a > b:
264 return False
265 return bool(self.reachableroots(a, [b], [a], includepath=False))
266
267 def parentrevs(self, rev):
268 n = self.node(rev)
269 hn = gitutil.togitnode(n)
270 c = self.gitrepo[hn]
271 p1 = p2 = nodemod.nullrev
272 if c.parents:
273 p1 = self.rev(c.parents[0].id.raw)
274 if len(c.parents) > 2:
275 raise error.Abort(b'TODO octopus merge handling')
276 if len(c.parents) == 2:
277 p2 = self.rev(c.parents[0].id.raw)
278 return p1, p2
279
280 # Private method is used at least by the tags code.
281 _uncheckedparentrevs = parentrevs
282
283 def commonancestorsheads(self, a, b):
284 # TODO the revlog verson of this has a C path, so we probably
285 # need to optimize this...
286 a, b = self.rev(a), self.rev(b)
287 return [
288 self.node(n)
289 for n in ancestor.commonancestorsheads(self.parentrevs, a, b)
290 ]
291
292 def branchinfo(self, rev):
293 """Git doesn't do named branches, so just put everything on default."""
294 return b'default', False
295
296 def delayupdate(self, tr):
297 # TODO: I think we can elide this because we're just dropping
298 # an object in the git repo?
299 pass
300
301 def add(
302 self,
303 manifest,
304 files,
305 desc,
306 transaction,
307 p1,
308 p2,
309 user,
310 date=None,
311 extra=None,
312 p1copies=None,
313 p2copies=None,
314 filesadded=None,
315 filesremoved=None,
316 ):
317 parents = []
318 hp1, hp2 = gitutil.togitnode(p1), gitutil.togitnode(p2)
319 if p1 != nodemod.nullid:
320 parents.append(hp1)
321 if p2 and p2 != nodemod.nullid:
322 parents.append(hp2)
323 assert date is not None
324 timestamp, tz = date
325 sig = pygit2.Signature(
326 encoding.unifromlocal(stringutil.person(user)),
327 encoding.unifromlocal(stringutil.email(user)),
328 timestamp,
329 -(tz // 60),
330 )
331 oid = self.gitrepo.create_commit(
332 None, sig, sig, desc, gitutil.togitnode(manifest), parents
333 )
334 # Set up an internal reference to force the commit into the
335 # changelog. Hypothetically, we could even use this refs/hg/
336 # namespace to allow for anonymous heads on git repos, which
337 # would be neat.
338 self.gitrepo.references.create(
339 'refs/hg/internal/latest-commit', oid, force=True
340 )
341 # Reindex now to pick up changes. We omit the progress
342 # callback because this will be very quick.
343 index._index_repo(self.gitrepo, self._db)
344 return oid.raw
345
346
347 class manifestlog(baselog):
348 def __getitem__(self, node):
349 return self.get(b'', node)
350
351 def get(self, relpath, node):
352 if node == nodemod.nullid:
353 # TODO: this should almost certainly be a memgittreemanifestctx
354 return manifest.memtreemanifestctx(self, relpath)
355 commit = self.gitrepo[gitutil.togitnode(node)]
356 t = commit.tree
357 if relpath:
358 parts = relpath.split(b'/')
359 for p in parts:
360 te = t[p]
361 t = self.gitrepo[te.id]
362 return gitmanifest.gittreemanifestctx(self.gitrepo, t)
363
364
365 @interfaceutil.implementer(repository.ifilestorage)
366 class filelog(baselog):
367 def __init__(self, gr, db, path):
368 super(filelog, self).__init__(gr, db)
369 assert isinstance(path, bytes)
370 self.path = path
371
372 def read(self, node):
373 if node == nodemod.nullid:
374 return b''
375 return self.gitrepo[gitutil.togitnode(node)].data
376
377 def lookup(self, node):
378 if len(node) not in (20, 40):
379 node = int(node)
380 if isinstance(node, int):
381 assert False, b'todo revnums for nodes'
382 if len(node) == 40:
383 node = nodemod.bin(node)
384 hnode = gitutil.togitnode(node)
385 if hnode in self.gitrepo:
386 return node
387 raise error.LookupError(self.path, node, _(b'no match found'))
388
389 def cmp(self, node, text):
390 """Returns True if text is different than content at `node`."""
391 return self.read(node) != text
392
393 def add(self, text, meta, transaction, link, p1=None, p2=None):
394 assert not meta # Should we even try to handle this?
395 return self.gitrepo.create_blob(text).raw
396
397 def __iter__(self):
398 for clrev in self._db.execute(
399 '''
400 SELECT rev FROM changelog
401 INNER JOIN changedfiles ON changelog.node = changedfiles.node
402 WHERE changedfiles.filename = ? AND changedfiles.filenode != ?
403 ''',
404 (pycompat.fsdecode(self.path), gitutil.nullgit),
405 ):
406 yield clrev[0]
407
408 def linkrev(self, fr):
409 return fr
410
411 def rev(self, node):
412 row = self._db.execute(
413 '''
414 SELECT rev FROM changelog
415 INNER JOIN changedfiles ON changelog.node = changedfiles.node
416 WHERE changedfiles.filename = ? AND changedfiles.filenode = ?''',
417 (pycompat.fsdecode(self.path), gitutil.togitnode(node)),
418 ).fetchone()
419 if row is None:
420 raise error.LookupError(self.path, node, _(b'no such node'))
421 return int(row[0])
422
423 def node(self, rev):
424 maybe = self._db.execute(
425 '''SELECT filenode FROM changedfiles
426 INNER JOIN changelog ON changelog.node = changedfiles.node
427 WHERE changelog.rev = ? AND filename = ?
428 ''',
429 (rev, pycompat.fsdecode(self.path)),
430 ).fetchone()
431 if maybe is None:
432 raise IndexError('gitlog %r out of range %d' % (self.path, rev))
433 return nodemod.bin(maybe[0])
434
435 def parents(self, node):
436 gn = gitutil.togitnode(node)
437 gp = pycompat.fsdecode(self.path)
438 ps = []
439 for p in self._db.execute(
440 '''SELECT p1filenode, p2filenode FROM changedfiles
441 WHERE filenode = ? AND filename = ?
442 ''',
443 (gn, gp),
444 ).fetchone():
445 if p is None:
446 commit = self._db.execute(
447 "SELECT node FROM changedfiles "
448 "WHERE filenode = ? AND filename = ?",
449 (gn, gp),
450 ).fetchone()[0]
451 # This filelog is missing some data. Build the
452 # filelog, then recurse (which will always find data).
453 if pycompat.ispy3:
454 commit = commit.decode('ascii')
455 index.fill_in_filelog(self.gitrepo, self._db, commit, gp, gn)
456 return self.parents(node)
457 else:
458 ps.append(nodemod.bin(p))
459 return ps
460
461 def renamed(self, node):
462 # TODO: renames/copies
463 return False
@@ -0,0 +1,26 b''
1 """utilities to assist in working with pygit2"""
2 from __future__ import absolute_import
3
4 from mercurial.node import bin, hex, nullid
5
6 from mercurial import pycompat
7
8
9 def togitnode(n):
10 """Wrapper to convert a Mercurial binary node to a unicode hexlified node.
11
12 pygit2 and sqlite both need nodes as strings, not bytes.
13 """
14 assert len(n) == 20
15 return pycompat.sysstr(hex(n))
16
17
18 def fromgitnode(n):
19 """Opposite of togitnode."""
20 assert len(n) == 40
21 if pycompat.ispy3:
22 return bin(n.encode('ascii'))
23 return bin(n)
24
25
26 nullgit = togitnode(nullid)
@@ -0,0 +1,346 b''
1 from __future__ import absolute_import
2
3 import collections
4 import os
5 import sqlite3
6
7 import pygit2
8
9 from mercurial.i18n import _
10
11 from mercurial import (
12 encoding,
13 error,
14 node as nodemod,
15 pycompat,
16 )
17
18 from . import gitutil
19
20
21 _CURRENT_SCHEMA_VERSION = 1
22 _SCHEMA = (
23 """
24 CREATE TABLE refs (
25 -- node and name are unique together. There may be more than one name for
26 -- a given node, and there may be no name at all for a given node (in the
27 -- case of an anonymous hg head).
28 node TEXT NOT NULL,
29 name TEXT
30 );
31
32 -- The "possible heads" of the repository, which we use to figure out
33 -- if we need to re-walk the changelog.
34 CREATE TABLE possible_heads (
35 node TEXT NOT NULL
36 );
37
38 -- The topological heads of the changelog, which hg depends on.
39 CREATE TABLE heads (
40 node TEXT NOT NULL
41 );
42
43 -- A total ordering of the changelog
44 CREATE TABLE changelog (
45 rev INTEGER NOT NULL PRIMARY KEY,
46 node TEXT NOT NULL,
47 p1 TEXT,
48 p2 TEXT
49 );
50
51 CREATE UNIQUE INDEX changelog_node_idx ON changelog(node);
52 CREATE UNIQUE INDEX changelog_node_rev_idx ON changelog(rev, node);
53
54 -- Changed files for each commit, which lets us dynamically build
55 -- filelogs.
56 CREATE TABLE changedfiles (
57 node TEXT NOT NULL,
58 filename TEXT NOT NULL,
59 -- 40 zeroes for deletions
60 filenode TEXT NOT NULL,
61 -- to handle filelog parentage:
62 p1node TEXT,
63 p1filenode TEXT,
64 p2node TEXT,
65 p2filenode TEXT
66 );
67
68 CREATE INDEX changedfiles_nodes_idx
69 ON changedfiles(node);
70
71 PRAGMA user_version=%d
72 """
73 % _CURRENT_SCHEMA_VERSION
74 )
75
76
77 def _createdb(path):
78 # print('open db', path)
79 # import traceback
80 # traceback.print_stack()
81 db = sqlite3.connect(encoding.strfromlocal(path))
82 db.text_factory = bytes
83
84 res = db.execute('PRAGMA user_version').fetchone()[0]
85
86 # New database.
87 if res == 0:
88 for statement in _SCHEMA.split(';'):
89 db.execute(statement.strip())
90
91 db.commit()
92
93 elif res == _CURRENT_SCHEMA_VERSION:
94 pass
95
96 else:
97 raise error.Abort(_(b'sqlite database has unrecognized version'))
98
99 db.execute('PRAGMA journal_mode=WAL')
100
101 return db
102
103
104 _OUR_ORDER = (
105 pygit2.GIT_SORT_TOPOLOGICAL | pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_REVERSE
106 )
107
108 _DIFF_FLAGS = 1 << 21 # GIT_DIFF_FORCE_BINARY, which isn't exposed by pygit2
109
110
111 def _find_nearest_ancestor_introducing_node(
112 db, gitrepo, file_path, walk_start, filenode
113 ):
114 """Find the nearest ancestor that introduces a file node.
115
116 Args:
117 db: a handle to our sqlite database.
118 gitrepo: A pygit2.Repository instance.
119 file_path: the path of a file in the repo
120 walk_start: a pygit2.Oid that is a commit where we should start walking
121 for our nearest ancestor.
122
123 Returns:
124 A hexlified SHA that is the commit ID of the next-nearest parent.
125 """
126 assert isinstance(file_path, str), 'file_path must be str, got %r' % type(
127 file_path
128 )
129 assert isinstance(filenode, str), 'filenode must be str, got %r' % type(
130 filenode
131 )
132 parent_options = {
133 row[0].decode('ascii')
134 for row in db.execute(
135 'SELECT node FROM changedfiles '
136 'WHERE filename = ? AND filenode = ?',
137 (file_path, filenode),
138 )
139 }
140 inner_walker = gitrepo.walk(walk_start, _OUR_ORDER)
141 for w in inner_walker:
142 if w.id.hex in parent_options:
143 return w.id.hex
144 raise error.ProgrammingError(
145 'Unable to find introducing commit for %s node %s from %s',
146 (file_path, filenode, walk_start),
147 )
148
149
150 def fill_in_filelog(gitrepo, db, startcommit, path, startfilenode):
151 """Given a starting commit and path, fill in a filelog's parent pointers.
152
153 Args:
154 gitrepo: a pygit2.Repository
155 db: a handle to our sqlite database
156 startcommit: a hexlified node id for the commit to start at
157 path: the path of the file whose parent pointers we should fill in.
158 filenode: the hexlified node id of the file at startcommit
159
160 TODO: make filenode optional
161 """
162 assert isinstance(
163 startcommit, str
164 ), 'startcommit must be str, got %r' % type(startcommit)
165 assert isinstance(
166 startfilenode, str
167 ), 'startfilenode must be str, got %r' % type(startfilenode)
168 visit = collections.deque([(startcommit, startfilenode)])
169 while visit:
170 cnode, filenode = visit.popleft()
171 commit = gitrepo[cnode]
172 parents = []
173 for parent in commit.parents:
174 t = parent.tree
175 for comp in path.split('/'):
176 try:
177 t = gitrepo[t[comp].id]
178 except KeyError:
179 break
180 else:
181 introducer = _find_nearest_ancestor_introducing_node(
182 db, gitrepo, path, parent.id, t.id.hex
183 )
184 parents.append((introducer, t.id.hex))
185 p1node = p1fnode = p2node = p2fnode = gitutil.nullgit
186 for par, parfnode in parents:
187 found = int(
188 db.execute(
189 'SELECT COUNT(*) FROM changedfiles WHERE '
190 'node = ? AND filename = ? AND filenode = ? AND '
191 'p1node NOT NULL',
192 (par, path, parfnode),
193 ).fetchone()[0]
194 )
195 if found == 0:
196 assert par is not None
197 visit.append((par, parfnode))
198 if parents:
199 p1node, p1fnode = parents[0]
200 if len(parents) == 2:
201 p2node, p2fnode = parents[1]
202 if len(parents) > 2:
203 raise error.ProgrammingError(
204 b"git support can't handle octopus merges"
205 )
206 db.execute(
207 'UPDATE changedfiles SET '
208 'p1node = ?, p1filenode = ?, p2node = ?, p2filenode = ? '
209 'WHERE node = ? AND filename = ? AND filenode = ?',
210 (p1node, p1fnode, p2node, p2fnode, commit.id.hex, path, filenode),
211 )
212 db.commit()
213
214
215 def _index_repo(gitrepo, db, progress_factory=lambda *args, **kwargs: None):
216 # Identify all references so we can tell the walker to visit all of them.
217 all_refs = gitrepo.listall_references()
218 possible_heads = set()
219 prog = progress_factory(b'refs')
220 for pos, ref in enumerate(all_refs):
221 if prog is not None:
222 prog.update(pos)
223 if not (
224 ref.startswith('refs/heads/') # local branch
225 or ref.startswith('refs/tags/') # tag
226 or ref.startswith('refs/remotes/') # remote branch
227 or ref.startswith('refs/hg/') # from this extension
228 ):
229 continue
230 try:
231 start = gitrepo.lookup_reference(ref).peel(pygit2.GIT_OBJ_COMMIT)
232 except ValueError:
233 # No commit to be found, so we don't care for hg's purposes.
234 continue
235 possible_heads.add(start.id)
236 # Optimization: if the list of heads hasn't changed, don't
237 # reindex, the changelog. This doesn't matter on small
238 # repositories, but on even moderately deep histories (eg cpython)
239 # this is a very important performance win.
240 #
241 # TODO: we should figure out how to incrementally index history
242 # (preferably by detecting rewinds!) so that we don't have to do a
243 # full changelog walk every time a new commit is created.
244 cache_heads = {x[0] for x in db.execute('SELECT node FROM possible_heads')}
245 walker = None
246 cur_cache_heads = {h.hex for h in possible_heads}
247 if cur_cache_heads == cache_heads:
248 return
249 for start in possible_heads:
250 if walker is None:
251 walker = gitrepo.walk(start, _OUR_ORDER)
252 else:
253 walker.push(start)
254
255 # Empty out the existing changelog. Even for large-ish histories
256 # we can do the top-level "walk all the commits" dance very
257 # quickly as long as we don't need to figure out the changed files
258 # list.
259 db.execute('DELETE FROM changelog')
260 if prog is not None:
261 prog.complete()
262 prog = progress_factory(b'commits')
263 # This walker is sure to visit all the revisions in history, but
264 # only once.
265 for pos, commit in enumerate(walker):
266 if prog is not None:
267 prog.update(pos)
268 p1 = p2 = nodemod.nullhex
269 if len(commit.parents) > 2:
270 raise error.ProgrammingError(
271 (
272 b"git support can't handle octopus merges, "
273 b"found a commit with %d parents :("
274 )
275 % len(commit.parents)
276 )
277 if commit.parents:
278 p1 = commit.parents[0].id.hex
279 if len(commit.parents) == 2:
280 p2 = commit.parents[1].id.hex
281 db.execute(
282 'INSERT INTO changelog (rev, node, p1, p2) VALUES(?, ?, ?, ?)',
283 (pos, commit.id.hex, p1, p2),
284 )
285
286 num_changedfiles = db.execute(
287 "SELECT COUNT(*) from changedfiles WHERE node = ?",
288 (commit.id.hex,),
289 ).fetchone()[0]
290 if not num_changedfiles:
291 files = {}
292 # I *think* we only need to check p1 for changed files
293 # (and therefore linkrevs), because any node that would
294 # actually have this commit as a linkrev would be
295 # completely new in this rev.
296 p1 = commit.parents[0].id.hex if commit.parents else None
297 if p1 is not None:
298 patchgen = gitrepo.diff(p1, commit.id.hex, flags=_DIFF_FLAGS)
299 else:
300 patchgen = commit.tree.diff_to_tree(
301 swap=True, flags=_DIFF_FLAGS
302 )
303 new_files = (p.delta.new_file for p in patchgen)
304 files = {
305 nf.path: nf.id.hex
306 for nf in new_files
307 if nf.id.raw != nodemod.nullid
308 }
309 for p, n in files.items():
310 # We intentionally set NULLs for any file parentage
311 # information so it'll get demand-computed later. We
312 # used to do it right here, and it was _very_ slow.
313 db.execute(
314 'INSERT INTO changedfiles ('
315 'node, filename, filenode, p1node, p1filenode, p2node, '
316 'p2filenode) VALUES(?, ?, ?, ?, ?, ?, ?)',
317 (commit.id.hex, p, n, None, None, None, None),
318 )
319 db.execute('DELETE FROM heads')
320 db.execute('DELETE FROM possible_heads')
321 for hid in possible_heads:
322 h = hid.hex
323 db.execute('INSERT INTO possible_heads (node) VALUES(?)', (h,))
324 haschild = db.execute(
325 'SELECT COUNT(*) FROM changelog WHERE p1 = ? OR p2 = ?', (h, h)
326 ).fetchone()[0]
327 if not haschild:
328 db.execute('INSERT INTO heads (node) VALUES(?)', (h,))
329
330 db.commit()
331 if prog is not None:
332 prog.complete()
333
334
335 def get_index(gitrepo, progress_factory=lambda *args, **kwargs: None):
336 cachepath = os.path.join(
337 pycompat.fsencode(gitrepo.path), b'..', b'.hg', b'cache'
338 )
339 if not os.path.exists(cachepath):
340 os.makedirs(cachepath)
341 dbpath = os.path.join(cachepath, b'git-commits.sqlite')
342 db = _createdb(dbpath)
343 # TODO check against gitrepo heads before doing a full index
344 # TODO thread a ui.progress call into this layer
345 _index_repo(gitrepo, db, progress_factory)
346 return db
@@ -0,0 +1,293 b''
1 from __future__ import absolute_import
2
3 import pygit2
4
5 from mercurial import (
6 match as matchmod,
7 pathutil,
8 pycompat,
9 util,
10 )
11 from mercurial.interfaces import (
12 repository,
13 util as interfaceutil,
14 )
15 from . import gitutil
16
17
18 @interfaceutil.implementer(repository.imanifestdict)
19 class gittreemanifest(object):
20 """Expose git trees (and optionally a builder's overlay) as a manifestdict.
21
22 Very similar to mercurial.manifest.treemanifest.
23 """
24
25 def __init__(self, git_repo, root_tree, pending_changes):
26 """Initializer.
27
28 Args:
29 git_repo: The git_repo we're walking (required to look up child
30 trees).
31 root_tree: The root Git tree object for this manifest.
32 pending_changes: A dict in which pending changes will be
33 tracked. The enclosing memgittreemanifestctx will use this to
34 construct any required Tree objects in Git during it's
35 `write()` method.
36 """
37 self._git_repo = git_repo
38 self._tree = root_tree
39 if pending_changes is None:
40 pending_changes = {}
41 # dict of path: Optional[Tuple(node, flags)]
42 self._pending_changes = pending_changes
43
44 def _resolve_entry(self, path):
45 """Given a path, load its node and flags, or raise KeyError if missing.
46
47 This takes into account any pending writes in the builder.
48 """
49 upath = pycompat.fsdecode(path)
50 ent = None
51 if path in self._pending_changes:
52 val = self._pending_changes[path]
53 if val is None:
54 raise KeyError
55 return val
56 t = self._tree
57 comps = upath.split('/')
58 for comp in comps[:-1]:
59 te = self._tree[comp]
60 t = self._git_repo[te.id]
61 ent = t[comps[-1]]
62 if ent.filemode == pygit2.GIT_FILEMODE_BLOB:
63 flags = b''
64 elif ent.filemode == pygit2.GIT_FILEMODE_BLOB_EXECUTABLE:
65 flags = b'x'
66 elif ent.filemode == pygit2.GIT_FILEMODE_LINK:
67 flags = b'l'
68 else:
69 raise ValueError('unsupported mode %s' % oct(ent.filemode))
70 return ent.id.raw, flags
71
72 def __getitem__(self, path):
73 return self._resolve_entry(path)[0]
74
75 def find(self, path):
76 return self._resolve_entry(path)
77
78 def __len__(self):
79 return len(list(self.walk(matchmod.always())))
80
81 def __nonzero__(self):
82 try:
83 next(iter(self))
84 return True
85 except StopIteration:
86 return False
87
88 __bool__ = __nonzero__
89
90 def __contains__(self, path):
91 try:
92 self._resolve_entry(path)
93 return True
94 except KeyError:
95 return False
96
97 def iterkeys(self):
98 return self.walk(matchmod.always())
99
100 def keys(self):
101 return list(self.iterkeys())
102
103 def __iter__(self):
104 return self.iterkeys()
105
106 def __setitem__(self, path, node):
107 self._pending_changes[path] = node, self.flags(path)
108
109 def __delitem__(self, path):
110 # TODO: should probably KeyError for already-deleted files?
111 self._pending_changes[path] = None
112
113 def filesnotin(self, other, match=None):
114 if match is not None:
115 match = matchmod.badmatch(match, lambda path, msg: None)
116 sm2 = set(other.walk(match))
117 return {f for f in self.walk(match) if f not in sm2}
118 return {f for f in self if f not in other}
119
120 @util.propertycache
121 def _dirs(self):
122 return pathutil.dirs(self)
123
124 def hasdir(self, dir):
125 return dir in self._dirs
126
127 def diff(self, other, match=None, clean=False):
128 # TODO
129 assert False
130
131 def setflag(self, path, flag):
132 node, unused_flag = self._resolve_entry(path)
133 self._pending_changes[path] = node, flag
134
135 def get(self, path, default=None):
136 try:
137 return self._resolve_entry(path)[0]
138 except KeyError:
139 return default
140
141 def flags(self, path):
142 try:
143 return self._resolve_entry(path)[1]
144 except KeyError:
145 return b''
146
147 def copy(self):
148 pass
149
150 def items(self):
151 for f in self:
152 # TODO: build a proper iterator version of this
153 yield self[f]
154
155 def iteritems(self):
156 return self.items()
157
158 def iterentries(self):
159 for f in self:
160 # TODO: build a proper iterator version of this
161 yield self._resolve_entry(f)
162
163 def text(self):
164 assert False # TODO can this method move out of the manifest iface?
165
166 def _walkonetree(self, tree, match, subdir):
167 for te in tree:
168 # TODO: can we prune dir walks with the matcher?
169 realname = subdir + pycompat.fsencode(te.name)
170 if te.type == r'tree':
171 for inner in self._walkonetree(
172 self._git_repo[te.id], match, realname + b'/'
173 ):
174 yield inner
175 if not match(realname):
176 continue
177 yield pycompat.fsencode(realname)
178
179 def walk(self, match):
180 # TODO: this is a very lazy way to merge in the pending
181 # changes. There is absolutely room for optimization here by
182 # being clever about walking over the sets...
183 baseline = set(self._walkonetree(self._tree, match, b''))
184 deleted = {p for p, v in self._pending_changes.items() if v is None}
185 pend = {p for p in self._pending_changes if match(p)}
186 return iter(sorted((baseline | pend) - deleted))
187
188
189 @interfaceutil.implementer(repository.imanifestrevisionstored)
190 class gittreemanifestctx(object):
191 def __init__(self, repo, gittree):
192 self._repo = repo
193 self._tree = gittree
194
195 def read(self):
196 return gittreemanifest(self._repo, self._tree, None)
197
198 def copy(self):
199 # NB: it's important that we return a memgittreemanifestctx
200 # because the caller expects a mutable manifest.
201 return memgittreemanifestctx(self._repo, self._tree)
202
203 def find(self, path):
204 self.read()[path]
205
206
207 @interfaceutil.implementer(repository.imanifestrevisionwritable)
208 class memgittreemanifestctx(object):
209 def __init__(self, repo, tree):
210 self._repo = repo
211 self._tree = tree
212 # dict of path: Optional[Tuple(node, flags)]
213 self._pending_changes = {}
214
215 def read(self):
216 return gittreemanifest(self._repo, self._tree, self._pending_changes)
217
218 def copy(self):
219 # TODO: if we have a builder in play, what should happen here?
220 # Maybe we can shuffle copy() into the immutable interface.
221 return memgittreemanifestctx(self._repo, self._tree)
222
223 def write(self, transaction, link, p1, p2, added, removed, match=None):
224 # We're not (for now, anyway) going to audit filenames, so we
225 # can ignore added and removed.
226
227 # TODO what does this match argument get used for? hopefully
228 # just narrow?
229 assert not match or isinstance(match, matchmod.alwaysmatcher)
230
231 touched_dirs = pathutil.dirs(self._pending_changes)
232 trees = {
233 b'': self._tree,
234 }
235 # path: treebuilder
236 builders = {
237 b'': self._repo.TreeBuilder(self._tree),
238 }
239 # get a TreeBuilder for every tree in the touched_dirs set
240 for d in sorted(touched_dirs, key=lambda x: (len(x), x)):
241 if d == b'':
242 # loaded root tree above
243 continue
244 comps = d.split(b'/')
245 full = b''
246 for part in comps:
247 parent = trees[full]
248 try:
249 new = self._repo[parent[pycompat.fsdecode(part)]]
250 except KeyError:
251 # new directory
252 new = None
253 full += b'/' + part
254 if new is not None:
255 # existing directory
256 trees[full] = new
257 builders[full] = self._repo.TreeBuilder(new)
258 else:
259 # new directory, use an empty dict to easily
260 # generate KeyError as any nested new dirs get
261 # created.
262 trees[full] = {}
263 builders[full] = self._repo.TreeBuilder()
264 for f, info in self._pending_changes.items():
265 if b'/' not in f:
266 dirname = b''
267 basename = f
268 else:
269 dirname, basename = f.rsplit(b'/', 1)
270 dirname = b'/' + dirname
271 if info is None:
272 builders[dirname].remove(pycompat.fsdecode(basename))
273 else:
274 n, fl = info
275 mode = {
276 b'': pygit2.GIT_FILEMODE_BLOB,
277 b'x': pygit2.GIT_FILEMODE_BLOB_EXECUTABLE,
278 b'l': pygit2.GIT_FILEMODE_LINK,
279 }[fl]
280 builders[dirname].insert(
281 pycompat.fsdecode(basename), gitutil.togitnode(n), mode
282 )
283 # This visits the buffered TreeBuilders in deepest-first
284 # order, bubbling up the edits.
285 for b in sorted(builders, key=len, reverse=True):
286 if b == b'':
287 break
288 cb = builders[b]
289 dn, bn = b.rsplit(b'/', 1)
290 builders[dn].insert(
291 pycompat.fsdecode(bn), cb.write(), pygit2.GIT_FILEMODE_TREE
292 )
293 return builders[b''].write().raw
@@ -0,0 +1,223 b''
1 This test requires pygit2:
2 > $PYTHON -c 'import pygit2' || exit 80
3
4 Setup:
5 > GIT_AUTHOR_NAME='test'; export GIT_AUTHOR_NAME
6 > GIT_AUTHOR_EMAIL='test@example.org'; export GIT_AUTHOR_EMAIL
7 > GIT_AUTHOR_DATE="2007-01-01 00:00:00 +0000"; export GIT_AUTHOR_DATE
8 > GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"; export GIT_COMMITTER_NAME
9 > GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"; export GIT_COMMITTER_EMAIL
10 > GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"; export GIT_COMMITTER_DATE
11
12 > count=10
13 > gitcommit() {
14 > GIT_AUTHOR_DATE="2007-01-01 00:00:$count +0000";
15 > GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"
16 > git commit "$@" >/dev/null 2>/dev/null || echo "git commit error"
17 > count=`expr $count + 1`
18 > }
19
20 > echo "[extensions]" >> $HGRCPATH
21 > echo "git=" >> $HGRCPATH
22
23 Make a new repo with git:
24 $ mkdir foo
25 $ cd foo
26 $ git init
27 Initialized empty Git repository in $TESTTMP/foo/.git/
28 Ignore the .hg directory within git:
29 $ echo .hg >> .git/info/exclude
30 $ echo alpha > alpha
31 $ git add alpha
32 $ gitcommit -am 'Add alpha'
33 $ echo beta > beta
34 $ git add beta
35 $ gitcommit -am 'Add beta'
36 $ echo gamma > gamma
37 $ git status
38 On branch master
39 Untracked files:
40 (use "git add <file>..." to include in what will be committed)
41 gamma
42
43 nothing added to commit but untracked files present (use "git add" to track)
44
45 Without creating the .hg, hg status fails:
46 $ hg status
47 abort: no repository found in '$TESTTMP/foo' (.hg not found)!
48 [255]
49 But if you run hg init --git, it works:
50 $ hg init --git
51 $ hg id --traceback
52 3d9be8deba43 tip master
53 $ hg status
54 ? gamma
55 Log works too:
56 $ hg log
57 changeset: 1:3d9be8deba43
58 bookmark: master
59 tag: tip
60 user: test <test@example.org>
61 date: Mon Jan 01 00:00:11 2007 +0000
62 summary: Add beta
63
64 changeset: 0:c5864c9d16fb
65 user: test <test@example.org>
66 date: Mon Jan 01 00:00:10 2007 +0000
67 summary: Add alpha
68
69
70
71 and bookmarks:
72 $ hg bookmarks
73 * master 1:3d9be8deba43
74
75 diff even works transparently in both systems:
76 $ echo blah >> alpha
77 $ git diff
78 diff --git a/alpha b/alpha
79 index 4a58007..faed1b7 100644
80 --- a/alpha
81 +++ b/alpha
82 @@ -1* +1,2 @@ (glob)
83 alpha
84 +blah
85 $ hg diff --git
86 diff --git a/alpha b/alpha
87 --- a/alpha
88 +++ b/alpha
89 @@ -1,1 +1,2 @@
90 alpha
91 +blah
92
93 Remove a file, it shows as such:
94 $ rm alpha
95 $ hg status
96 ! alpha
97 ? gamma
98
99 Revert works:
100 $ hg revert alpha --traceback
101 $ hg status
102 ? gamma
103 $ git status
104 On branch master
105 Untracked files:
106 (use "git add <file>..." to include in what will be committed)
107 gamma
108
109 nothing added to commit but untracked files present (use "git add" to track)
110
111 Add shows sanely in both:
112 $ hg add gamma
113 $ hg status
114 A gamma
115 $ hg files
116 alpha
117 beta
118 gamma
119 $ git ls-files
120 alpha
121 beta
122 gamma
123 $ git status
124 On branch master
125 Changes to be committed:
126 (use "git restore --staged <file>..." to unstage)
127 new file: gamma
128
129
130 forget does what it should as well:
131 $ hg forget gamma
132 $ hg status
133 ? gamma
134 $ git status
135 On branch master
136 Untracked files:
137 (use "git add <file>..." to include in what will be committed)
138 gamma
139
140 nothing added to commit but untracked files present (use "git add" to track)
141
142 clean up untracked file
143 $ rm gamma
144
145 hg log FILE
146
147 $ echo a >> alpha
148 $ hg ci -m 'more alpha' --traceback --date '1583522787 18000'
149 $ echo b >> beta
150 $ hg ci -m 'more beta'
151 $ echo a >> alpha
152 $ hg ci -m 'even more alpha'
153 $ hg log -G alpha
154 @ changeset: 4:6626247b7dc8
155 : bookmark: master
156 : tag: tip
157 : user: test <test>
158 : date: Thu Jan 01 00:00:00 1970 +0000
159 : summary: even more alpha
160 :
161 o changeset: 2:a1983dd7fb19
162 : user: test <test>
163 : date: Fri Mar 06 14:26:27 2020 -0500
164 : summary: more alpha
165 :
166 o changeset: 0:c5864c9d16fb
167 user: test <test@example.org>
168 date: Mon Jan 01 00:00:10 2007 +0000
169 summary: Add alpha
170
171 $ hg log -G beta
172 o changeset: 3:d8ee22687733
173 : user: test <test>
174 : date: Thu Jan 01 00:00:00 1970 +0000
175 : summary: more beta
176 :
177 o changeset: 1:3d9be8deba43
178 | user: test <test@example.org>
179 ~ date: Mon Jan 01 00:00:11 2007 +0000
180 summary: Add beta
181
182
183 node|shortest works correctly
184 $ hg log -r tip --template "{node|shortest}\n"
185 6626
186
187 hg annotate
188
189 $ hg annotate alpha
190 0: alpha
191 2: a
192 4: a
193 $ hg annotate beta
194 1: beta
195 3: b
196
197
198 Files in subdirectories. TODO: case-folding support, make this `A`
199 instead of `a`.
200
201 $ mkdir a
202 $ echo "This is file mu." > a/mu
203 $ hg ci -A -m 'Introduce file a/mu'
204 adding a/mu
205
206 Both hg and git agree a/mu is part of the repo
207
208 $ git ls-files
209 a/mu
210 alpha
211 beta
212 $ hg files
213 a/mu
214 alpha
215 beta
216
217 hg and git status both clean
218
219 $ git status
220 On branch master
221 nothing to commit, working tree clean
222 $ hg status
223
@@ -1214,6 +1214,7 b' packages = ['
1214 'hgext.fsmonitor',
1214 'hgext.fsmonitor',
1215 'hgext.fastannotate',
1215 'hgext.fastannotate',
1216 'hgext.fsmonitor.pywatchman',
1216 'hgext.fsmonitor.pywatchman',
1217 'hgext.git',
1217 'hgext.highlight',
1218 'hgext.highlight',
1218 'hgext.hooklib',
1219 'hgext.hooklib',
1219 'hgext.infinitepush',
1220 'hgext.infinitepush',
General Comments 0
You need to be logged in to leave comments. Login now