Coverage for gsb/history.py: 100%

67 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-09 21:03 +0000

1"""Functionality for tracking and managing revision history""" 

2 

3import datetime as dt 

4import logging 

5from collections import defaultdict 

6from pathlib import Path 

7from typing import Any, TypedDict 

8 

9from . import _git 

10from .logging import IMPORTANT 

11 

12LOGGER = logging.getLogger(__name__) 

13 

14 

15class Revision(TypedDict): 

16 """Metadata on a GSB-managed version 

17 

18 Parameters 

19 ---------- 

20 identifier : str 

21 A short, unique identifier for the revision 

22 commit_hash : str 

23 The full hexadecimal hash associated with the revision 

24 description : str 

25 A description of the version 

26 timestamp : dt.datetime 

27 The time at which the version was created 

28 tagged : bool 

29 Whether or not this is a tagged revision 

30 gsb : bool 

31 Whether or not this is a GSB-created revision 

32 """ 

33 

34 identifier: str 

35 commit_hash: str 

36 description: str 

37 timestamp: dt.datetime 

38 tagged: bool 

39 gsb: bool 

40 

41 

42def get_history( 

43 repo_root: Path, 

44 tagged_only: bool = True, 

45 include_non_gsb: bool = False, 

46 limit: int = -1, 

47 since: dt.date = dt.datetime(1970, 1, 1), 

48 since_last_tagged_backup: bool = False, 

49 always_include_latest: bool = False, 

50) -> list[Revision]: 

51 """Retrieve a list of GSB-managed versions 

52 

53 Parameters 

54 ---------- 

55 repo_root : Path 

56 The directory containing the GSB-managed repo 

57 tagged_only : bool, optional 

58 By default, this method only returns tagged backups. To include 

59 all available revisions, pass in `tagged_only=False`. 

60 include_non_gsb : bool, optional 

61 By default, this method excludes any revisions created outside of `gsb`. 

62 To include all git commits and tags, pass in `include_non_gsb=True`. 

63 limit : int, optional 

64 By default, this method returns the entire history. To return only the 

65 last _n_ revisions, pass in `limit=n`. 

66 since : date or timestamp, optional 

67 By default, this method returns the entire history. To return only 

68 revisions made on or after a certain date, pass in `since=<start_date>`. 

69 since_last_tagged_backup: bool, optional 

70 False by default. To return only revisions made since the last tagged 

71 backup, pass in `since_last_tagged_backup=True` (and, presumably, 

72 `tagged_only=False`). This flag is compatible with all other filters. 

73 always_include_latest: bool, optional 

74 Whether to always include the latest backup, whether or not it's 

75 tagged or GSB-managed, and ignoring any other options. Default is False. 

76 

77 Returns 

78 ------- 

79 list of dict 

80 metadata on the requested revisions, sorted in reverse-chronological 

81 order 

82 

83 Raises 

84 ------ 

85 OSError 

86 If the specified repo does not exist or is not a git repo 

87 ValueError 

88 If called with `return_parent=True` `get_history` and the earliest commit 

89 had no parent (being the initial commit, itself). 

90 """ 

91 tag_lookup = defaultdict(list) 

92 tag_count = 0 

93 for tag in _git.get_tags(repo_root, annotated_only=True): 

94 tag_lookup[tag.target].append(tag) 

95 tag_count += 1 

96 LOGGER.debug("Retrieved %s tags", tag_count) 

97 

98 revisions: list[Revision] = [] 

99 defer_break = False 

100 for commit in _git.log(repo_root): 

101 if len(revisions) == limit or commit.timestamp < since: 

102 if always_include_latest: 

103 defer_break = True 

104 else: 

105 break 

106 if tags := tag_lookup.get(commit): 

107 if since_last_tagged_backup: 

108 break 

109 tagged = True 

110 for tag in reversed(tags): 

111 identifier = tag.name 

112 is_gsb = tag.gsb if tag.gsb is not None else commit.gsb 

113 description = tag.annotation or commit.message 

114 if is_gsb or include_non_gsb: 

115 revisions.append( 

116 { 

117 "identifier": identifier, 

118 "commit_hash": commit.hash, 

119 "description": description.strip(), 

120 "timestamp": commit.timestamp, 

121 "tagged": tagged, 

122 "gsb": is_gsb, 

123 } 

124 ) 

125 else: 

126 if tagged_only and not always_include_latest: 

127 continue 

128 tagged = False 

129 identifier = commit.hash[:8] 

130 is_gsb = commit.gsb 

131 description = commit.message 

132 if not include_non_gsb and not is_gsb and not always_include_latest: 

133 continue 

134 revisions.append( 

135 { 

136 "identifier": identifier, 

137 "commit_hash": commit.hash, 

138 "description": description.strip(), 

139 "timestamp": commit.timestamp, 

140 "tagged": tagged, 

141 "gsb": is_gsb, 

142 } 

143 ) 

144 if always_include_latest: 

145 if defer_break: 

146 break 

147 always_include_latest = False 

148 return revisions 

149 

150 

151def log_revision(revision: Revision, idx: int | None) -> None: 

152 """Print (log) a revision 

153 

154 Parameters 

155 ---------- 

156 revision : dict 

157 Metadata for the revision 

158 idx : int | None 

159 The index to give to the revision. If None is specified, the revision 

160 will be displayed with a "-" instead of a numbering. 

161 

162 Notes 

163 ----- 

164 - The version identifiers and dates are logged at the IMPORTANT (verbose=0) level 

165 - The version descriptions are logged at the INFO (verbose=1) level 

166 - The full version hashes are logged at the DEBUG (verbose=2) level 

167 """ 

168 args: list[Any] = [revision["identifier"], revision["timestamp"].isoformat("-")] 

169 if idx is None: 

170 format_string = "- %s from %s" 

171 else: 

172 format_string = "%d. %s from %s" 

173 args.insert(0, idx) 

174 

175 LOGGER.log(IMPORTANT, format_string, *args) 

176 

177 LOGGER.debug("Full reference: %s", revision["commit_hash"]) 

178 LOGGER.info("%s", revision["description"]) 

179 

180 

181def show_history( 

182 repo_root: Path, 

183 numbering: int | None = 1, 

184 **kwargs, 

185) -> list[Revision]: 

186 """Fetch and print (log) the list of versions for the specified repo matching 

187 the given specs 

188 

189 Parameters 

190 ---------- 

191 repo_root : Path 

192 The directory containing the GSB-managed repo 

193 numbering: int or None, optional 

194 When displaying the versions, the default behavior is to number the 

195 results, starting at 1. To set a different starting number, provide that. 

196 To use "-" instead of numbers, pass in `numbering=None`. 

197 **kwargs 

198 Any other options will be passed directly to `get_history()` 

199 method 

200 

201 Notes 

202 ----- 

203 See `log_revision()` for details about what information is logged to each 

204 log level 

205 

206 Returns 

207 ------- 

208 list of dict 

209 metadata on the requested revisions, sorted in reverse-chronological 

210 order 

211 

212 Raises 

213 ------ 

214 OSError 

215 If the specified repo does not exist or is not a git repo 

216 """ 

217 history = get_history(repo_root, **kwargs) 

218 for i, revision in enumerate(history): 

219 log_revision(revision, i + numbering if numbering is not None else None) 

220 return history