diff --git a/dulwich/reflog.py b/dulwich/reflog.py index 73d11886..0574ddc3 100644 --- a/dulwich/reflog.py +++ b/dulwich/reflog.py @@ -1,95 +1,154 @@ # reflog.py -- Parsing and writing reflog files # Copyright (C) 2015 Jelmer Vernooij and others. # # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU # General Public License as public by the Free Software Foundation; version 2.0 # or (at your option) any later version. You can redistribute it and/or # modify it under the terms of either of these two licenses. # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # You should have received a copy of the licenses; if not, see # for a copy of the GNU General Public License # and for a copy of the Apache # License, Version 2.0. # """Utilities for reading and generating reflogs. """ import collections from dulwich.objects import ( format_timezone, parse_timezone, ZERO_SHA, ) Entry = collections.namedtuple( "Entry", ["old_sha", "new_sha", "committer", "timestamp", "timezone", "message"], ) def format_reflog_line(old_sha, new_sha, committer, timestamp, timezone, message): """Generate a single reflog line. Args: old_sha: Old Commit SHA new_sha: New Commit SHA committer: Committer name and e-mail timestamp: Timestamp timezone: Timezone message: Message """ if old_sha is None: old_sha = ZERO_SHA return ( old_sha + b" " + new_sha + b" " + committer + b" " + str(int(timestamp)).encode("ascii") + b" " + format_timezone(timezone) + b"\t" + message ) def parse_reflog_line(line): """Parse a reflog line. Args: line: Line to parse Returns: Tuple of (old_sha, new_sha, committer, timestamp, timezone, message) """ (begin, message) = line.split(b"\t", 1) (old_sha, new_sha, rest) = begin.split(b" ", 2) (committer, timestamp_str, timezone_str) = rest.rsplit(b" ", 2) return Entry( old_sha, new_sha, committer, int(timestamp_str), parse_timezone(timezone_str)[0], message, ) def read_reflog(f): """Read reflog. Args: f: File-like object Returns: Iterator over Entry objects """ for line in f: yield parse_reflog_line(line) + + +def drop_reflog_entry(f, index, rewrite=False): + """Drop the specified reflog entry. + + Args: + f: File-like object + index: Reflog entry index (in Git reflog reverse 0-indexed order) + rewrite: If a reflog entry's predecessor is removed, set its + old SHA to the new SHA of the entry that now precedes it + """ + if index < 0: + raise ValueError("Invalid reflog index %d" % index) + + log = [] + offset = f.tell() + for line in f: + log.append((offset, parse_reflog_line(line))) + offset = f.tell() + + inverse_index = len(log) - index - 1 + write_offset = log[inverse_index][0] + f.seek(write_offset) + + if index == 0: + f.truncate() + return + + del log[inverse_index] + if rewrite and index > 0 and log: + if inverse_index == 0: + previous_new = ZERO_SHA + else: + previous_new = log[inverse_index - 1][1].new_sha + offset, entry = log[inverse_index] + log[inverse_index] = ( + offset, + Entry( + previous_new, + entry.new_sha, + entry.committer, + entry.timestamp, + entry.timezone, + entry.message, + ), + ) + + for _, entry in log[inverse_index:]: + f.write( + format_reflog_line( + entry.old_sha, + entry.new_sha, + entry.committer, + entry.timestamp, + entry.timezone, + entry.message, + ) + ) + f.truncate() diff --git a/dulwich/stash.py b/dulwich/stash.py index 11a28f86..a2b6e13a 100644 --- a/dulwich/stash.py +++ b/dulwich/stash.py @@ -1,125 +1,135 @@ # stash.py # Copyright (C) 2018 Jelmer Vernooij # # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU # General Public License as public by the Free Software Foundation; version 2.0 # or (at your option) any later version. You can redistribute it and/or # modify it under the terms of either of these two licenses. # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # You should have received a copy of the licenses; if not, see # for a copy of the GNU General Public License # and for a copy of the Apache # License, Version 2.0. # """Stash handling.""" from __future__ import absolute_import import os from dulwich.file import GitFile from dulwich.index import ( commit_tree, iter_fresh_objects, ) -from dulwich.reflog import read_reflog +from dulwich.reflog import drop_reflog_entry, read_reflog DEFAULT_STASH_REF = b"refs/stash" class Stash(object): """A Git stash. Note that this doesn't currently update the working tree. """ def __init__(self, repo, ref=DEFAULT_STASH_REF): self._ref = ref self._repo = repo - def stashes(self): - reflog_path = os.path.join( + @property + def _reflog_path(self): + return os.path.join( self._repo.commondir(), "logs", os.fsdecode(self._ref) ) + + def stashes(self): try: - with GitFile(reflog_path, "rb") as f: + with GitFile(self._reflog_path, "rb") as f: return reversed(list(read_reflog(f))) except FileNotFoundError: return [] @classmethod def from_repo(cls, repo): """Create a new stash from a Repo object.""" return cls(repo) def drop(self, index): """Drop entry with specified index.""" - raise NotImplementedError(self.drop) + with open(self._reflog_path, "rb+") as f: + drop_reflog_entry(f, index, rewrite=True) + if len(self) == 0: + os.remove(self._reflog_path) + del self._repo.refs[self._ref] + return + if index == 0: + self._repo.refs[self._ref] = self[0].new_sha def pop(self, index): - raise NotImplementedError(self.drop) + raise NotImplementedError(self.pop) def push(self, committer=None, author=None, message=None): """Create a new stash. Args: committer: Optional committer name to use author: Optional author name to use message: Optional commit message """ # First, create the index commit. commit_kwargs = {} if committer is not None: commit_kwargs["committer"] = committer if author is not None: commit_kwargs["author"] = author index = self._repo.open_index() index_tree_id = index.commit(self._repo.object_store) index_commit_id = self._repo.do_commit( ref=None, tree=index_tree_id, message=b"Index stash", merge_heads=[self._repo.head()], **commit_kwargs ) # Then, the working tree one. stash_tree_id = commit_tree( self._repo.object_store, iter_fresh_objects( index, os.fsencode(self._repo.path), object_store=self._repo.object_store, ), ) if message is None: message = b"A stash on " + self._repo.head() # TODO(jelmer): Just pass parents into do_commit()? self._repo.refs[self._ref] = self._repo.head() cid = self._repo.do_commit( ref=self._ref, tree=stash_tree_id, message=message, merge_heads=[index_commit_id], **commit_kwargs ) return cid def __getitem__(self, index): return list(self.stashes())[index] def __len__(self): return len(list(self.stashes())) diff --git a/dulwich/tests/test_reflog.py b/dulwich/tests/test_reflog.py index 2f6df051..c8bfcaf5 100644 --- a/dulwich/tests/test_reflog.py +++ b/dulwich/tests/test_reflog.py @@ -1,84 +1,146 @@ # test_reflog.py -- tests for reflog.py # encoding: utf-8 # Copyright (C) 2015 Jelmer Vernooij # # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU # General Public License as public by the Free Software Foundation; version 2.0 # or (at your option) any later version. You can redistribute it and/or # modify it under the terms of either of these two licenses. # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # You should have received a copy of the licenses; if not, see # for a copy of the GNU General Public License # and for a copy of the Apache # License, Version 2.0. # """Tests for dulwich.reflog.""" +from io import BytesIO +from dulwich.objects import ZERO_SHA from dulwich.reflog import ( + drop_reflog_entry, format_reflog_line, parse_reflog_line, + read_reflog, ) from dulwich.tests import ( TestCase, ) class ReflogLineTests(TestCase): def test_format(self): self.assertEqual( b"0000000000000000000000000000000000000000 " b"49030649db3dfec5a9bc03e5dde4255a14499f16 Jelmer Vernooij " b" 1446552482 +0000 " b"clone: from git://jelmer.uk/samba", format_reflog_line( b"0000000000000000000000000000000000000000", b"49030649db3dfec5a9bc03e5dde4255a14499f16", b"Jelmer Vernooij ", 1446552482, 0, b"clone: from git://jelmer.uk/samba", ), ) self.assertEqual( b"0000000000000000000000000000000000000000 " b"49030649db3dfec5a9bc03e5dde4255a14499f16 Jelmer Vernooij " b" 1446552482 +0000 " b"clone: from git://jelmer.uk/samba", format_reflog_line( None, b"49030649db3dfec5a9bc03e5dde4255a14499f16", b"Jelmer Vernooij ", 1446552482, 0, b"clone: from git://jelmer.uk/samba", ), ) def test_parse(self): reflog_line = ( b"0000000000000000000000000000000000000000 " b"49030649db3dfec5a9bc03e5dde4255a14499f16 Jelmer Vernooij " b" 1446552482 +0000 " b"clone: from git://jelmer.uk/samba" ) self.assertEqual( ( b"0000000000000000000000000000000000000000", b"49030649db3dfec5a9bc03e5dde4255a14499f16", b"Jelmer Vernooij ", 1446552482, 0, b"clone: from git://jelmer.uk/samba", ), parse_reflog_line(reflog_line), ) + + +_TEST_REFLOG = ( + b"0000000000000000000000000000000000000000 " + b"49030649db3dfec5a9bc03e5dde4255a14499f16 Jelmer Vernooij " + b" 1446552482 +0000 " + b"clone: from git://jelmer.uk/samba\n" + b"49030649db3dfec5a9bc03e5dde4255a14499f16 " + b"42d06bd4b77fed026b154d16493e5deab78f02ec Jelmer Vernooij " + b" 1446552483 +0000 " + b"clone: from git://jelmer.uk/samba\n" + b"42d06bd4b77fed026b154d16493e5deab78f02ec " + b"df6800012397fb85c56e7418dd4eb9405dee075c Jelmer Vernooij " + b" 1446552484 +0000 " + b"clone: from git://jelmer.uk/samba\n" +) + + +class ReflogDropTests(TestCase): + def setUp(self): + TestCase.setUp(self) + self.f = BytesIO(_TEST_REFLOG) + self.original_log = list(read_reflog(self.f)) + self.f.seek(0) + + def _read_log(self): + self.f.seek(0) + return list(read_reflog(self.f)) + + def test_invalid(self): + self.assertRaises(ValueError, drop_reflog_entry, self.f, -1) + + def test_drop_entry(self): + drop_reflog_entry(self.f, 0) + log = self._read_log() + self.assertEqual(len(log), 2) + self.assertEqual(self.original_log[0:2], log) + + self.f.seek(0) + drop_reflog_entry(self.f, 1) + log = self._read_log() + self.assertEqual(len(log), 1) + self.assertEqual(self.original_log[1], log[0]) + + def test_drop_entry_with_rewrite(self): + drop_reflog_entry(self.f, 1, True) + log = self._read_log() + self.assertEqual(len(log), 2) + self.assertEqual(self.original_log[0], log[0]) + self.assertEqual(self.original_log[0].new_sha, log[1].old_sha) + self.assertEqual(self.original_log[2].new_sha, log[1].new_sha) + + self.f.seek(0) + drop_reflog_entry(self.f, 1, True) + log = self._read_log() + self.assertEqual(len(log), 1) + self.assertEqual(ZERO_SHA, log[0].old_sha) + self.assertEqual(self.original_log[2].new_sha, log[0].new_sha)