Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/dulwich/reflog.py: 20%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# reflog.py -- Parsing and writing reflog files
2# Copyright (C) 2015 Jelmer Vernooij and others.
3#
4# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
5# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
6# General Public License as published by the Free Software Foundation; version 2.0
7# or (at your option) any later version. You can redistribute it and/or
8# modify it under the terms of either of these two licenses.
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15#
16# You should have received a copy of the licenses; if not, see
17# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
18# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
19# License, Version 2.0.
20#
22"""Utilities for reading and generating reflogs."""
24__all__ = [
25 "drop_reflog_entry",
26 "expire_reflog",
27 "format_reflog_line",
28 "iter_reflogs",
29 "parse_reflog_line",
30 "parse_reflog_spec",
31 "read_reflog",
32]
34import collections
35import os
36from collections.abc import Callable, Generator
37from pathlib import Path
38from typing import IO, BinaryIO
40from .file import _GitFile
41from .objects import ZERO_SHA, format_timezone, parse_timezone
43Entry = collections.namedtuple(
44 "Entry",
45 ["old_sha", "new_sha", "committer", "timestamp", "timezone", "message"],
46)
49def parse_reflog_spec(refspec: str | bytes) -> tuple[bytes, int]:
50 """Parse a reflog specification like 'HEAD@{1}' or 'refs/heads/master@{2}'.
52 Args:
53 refspec: Reflog specification (e.g., 'HEAD@{1}', 'master@{0}')
55 Returns:
56 Tuple of (ref_name, index) where index is in Git reflog order (0 = newest)
58 Raises:
59 ValueError: If the refspec is not a valid reflog specification
60 """
61 if isinstance(refspec, str):
62 refspec = refspec.encode("utf-8")
64 if b"@{" not in refspec:
65 raise ValueError(
66 f"Invalid reflog spec: {refspec!r}. Expected format: ref@{{n}}"
67 )
69 ref, rest = refspec.split(b"@{", 1)
70 if not rest.endswith(b"}"):
71 raise ValueError(
72 f"Invalid reflog spec: {refspec!r}. Expected format: ref@{{n}}"
73 )
75 index_str = rest[:-1]
76 if not index_str.isdigit():
77 raise ValueError(
78 f"Invalid reflog index: {index_str!r}. Expected integer in ref@{{n}}"
79 )
81 # Use HEAD if no ref specified (e.g., "@{1}")
82 if not ref:
83 ref = b"HEAD"
85 return ref, int(index_str)
88def format_reflog_line(
89 old_sha: bytes | None,
90 new_sha: bytes,
91 committer: bytes,
92 timestamp: int | float,
93 timezone: int,
94 message: bytes,
95) -> bytes:
96 """Generate a single reflog line.
98 Args:
99 old_sha: Old Commit SHA
100 new_sha: New Commit SHA
101 committer: Committer name and e-mail
102 timestamp: Timestamp
103 timezone: Timezone
104 message: Message
105 """
106 if old_sha is None:
107 old_sha = ZERO_SHA
108 # A reflog entry is a single line and the message is the last, tab-separated
109 # field. Collapse embedded whitespace (in particular newlines) to single
110 # spaces so a multi-line or crafted message cannot split into extra physical
111 # lines and forge additional reflog entries. Matches Git's copy_reflog_msg.
112 message = b" ".join(message.split())
113 return (
114 old_sha
115 + b" "
116 + new_sha
117 + b" "
118 + committer
119 + b" "
120 + str(int(timestamp)).encode("ascii")
121 + b" "
122 + format_timezone(timezone)
123 + b"\t"
124 + message
125 )
128def parse_reflog_line(line: bytes) -> Entry:
129 """Parse a reflog line.
131 Args:
132 line: Line to parse
133 Returns: Tuple of (old_sha, new_sha, committer, timestamp, timezone,
134 message)
135 """
136 (begin, message) = line.split(b"\t", 1)
137 (old_sha, new_sha, rest) = begin.split(b" ", 2)
138 (committer, timestamp_str, timezone_str) = rest.rsplit(b" ", 2)
139 return Entry(
140 old_sha,
141 new_sha,
142 committer,
143 int(timestamp_str),
144 parse_timezone(timezone_str)[0],
145 message,
146 )
149def read_reflog(
150 f: BinaryIO | IO[bytes] | _GitFile,
151) -> Generator[Entry, None, None]:
152 """Read reflog.
154 Args:
155 f: File-like object
156 Returns: Iterator over Entry objects
157 """
158 for line in f:
159 yield parse_reflog_line(line.rstrip(b"\n"))
162def drop_reflog_entry(f: BinaryIO, index: int, rewrite: bool = False) -> None:
163 """Drop the specified reflog entry.
165 Args:
166 f: File-like object
167 index: Reflog entry index (in Git reflog reverse 0-indexed order)
168 rewrite: If a reflog entry's predecessor is removed, set its
169 old SHA to the new SHA of the entry that now precedes it
170 """
171 if index < 0:
172 raise ValueError(f"Invalid reflog index {index}")
174 log = []
175 offset = f.tell()
176 for line in f:
177 log.append((offset, parse_reflog_line(line.rstrip(b"\n"))))
178 offset = f.tell()
180 inverse_index = len(log) - index - 1
181 write_offset = log[inverse_index][0]
182 f.seek(write_offset)
184 if index == 0:
185 f.truncate()
186 return
188 del log[inverse_index]
189 if rewrite and index > 0 and log:
190 if inverse_index == 0:
191 previous_new = ZERO_SHA
192 else:
193 previous_new = log[inverse_index - 1][1].new_sha
194 offset, entry = log[inverse_index]
195 log[inverse_index] = (
196 offset,
197 Entry(
198 previous_new,
199 entry.new_sha,
200 entry.committer,
201 entry.timestamp,
202 entry.timezone,
203 entry.message,
204 ),
205 )
207 for _, entry in log[inverse_index:]:
208 f.write(
209 format_reflog_line(
210 entry.old_sha,
211 entry.new_sha,
212 entry.committer,
213 entry.timestamp,
214 entry.timezone,
215 entry.message,
216 )
217 + b"\n"
218 )
219 f.truncate()
222def expire_reflog(
223 f: BinaryIO,
224 expire_time: int | None = None,
225 expire_unreachable_time: int | None = None,
226 reachable_checker: Callable[[bytes], bool] | None = None,
227) -> int:
228 """Expire reflog entries based on age and reachability.
230 Args:
231 f: File-like object for the reflog
232 expire_time: Expire entries older than this timestamp (seconds since epoch).
233 If None, entries are not expired based on age alone.
234 expire_unreachable_time: Expire unreachable entries older than this
235 timestamp. If None, unreachable entries are not expired.
236 reachable_checker: Optional callable that takes a SHA and returns True
237 if the commit is reachable. If None, all entries are considered
238 reachable.
240 Returns:
241 Number of entries expired
242 """
243 if expire_time is None and expire_unreachable_time is None:
244 return 0
246 entries = []
247 offset = f.tell()
248 for line in f:
249 entries.append((offset, parse_reflog_line(line.rstrip(b"\n"))))
250 offset = f.tell()
252 # Filter entries that should be kept
253 kept_entries = []
254 expired_count = 0
256 for offset, entry in entries:
257 should_expire = False
259 # Check if entry is reachable
260 is_reachable = True
261 if reachable_checker is not None:
262 is_reachable = reachable_checker(entry.new_sha)
264 # Apply expiration rules
265 # Check the appropriate expiration time based on reachability
266 if is_reachable:
267 if expire_time is not None and entry.timestamp < expire_time:
268 should_expire = True
269 else:
270 if (
271 expire_unreachable_time is not None
272 and entry.timestamp < expire_unreachable_time
273 ):
274 should_expire = True
276 if should_expire:
277 expired_count += 1
278 else:
279 kept_entries.append((offset, entry))
281 # Write back the kept entries
282 if expired_count > 0:
283 f.seek(0)
284 for _, entry in kept_entries:
285 f.write(
286 format_reflog_line(
287 entry.old_sha,
288 entry.new_sha,
289 entry.committer,
290 entry.timestamp,
291 entry.timezone,
292 entry.message,
293 )
294 + b"\n"
295 )
296 f.truncate()
298 return expired_count
301def iter_reflogs(logs_dir: str) -> Generator[bytes, None, None]:
302 """Iterate over all reflogs in a repository.
304 Args:
305 logs_dir: Path to the logs directory (e.g., .git/logs)
307 Yields:
308 Reference names (as bytes) that have reflogs
309 """
310 if not os.path.exists(logs_dir):
311 return
313 logs_path = Path(logs_dir)
314 for log_file in logs_path.rglob("*"):
315 if log_file.is_file():
316 # Get the ref name by removing the logs_dir prefix
317 ref_name = str(log_file.relative_to(logs_path))
318 # Convert path separators to / for refs
319 ref_name = ref_name.replace(os.sep, "/")
320 yield ref_name.encode("utf-8")