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 |
General Comments 0
You need to be logged in to leave comments.
Login now