article-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,356 @@
1
+ """
2
+ Git operations and release management
3
+
4
+ Handles git hooks setup, release creation, and LaTeX build file cleanup.
5
+ """
6
+
7
+ import re
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import List, Optional
11
+ import shutil
12
+
13
+ from .zotero import print_success, print_error, print_warning, print_info, Colors
14
+
15
+
16
+ class GitManager:
17
+ """Git operations manager for article repositories"""
18
+
19
+ def __init__(self, repo_root: Optional[Path] = None):
20
+ """
21
+ Initialize Git manager
22
+
23
+ Args:
24
+ repo_root: Repository root directory (defaults to current directory)
25
+ """
26
+ self.repo_root = repo_root or Path.cwd()
27
+ self._validate_git_repo()
28
+
29
+ def _validate_git_repo(self) -> None:
30
+ """Validate that the current directory is a git repository"""
31
+ git_dir = self.repo_root / ".git"
32
+ if not git_dir.exists():
33
+ raise ValueError(f"Not a git repository: {self.repo_root}")
34
+
35
+ def setup_hooks(self) -> bool:
36
+ """Setup git hooks for gitinfo2"""
37
+ try:
38
+ hooks_dir = self.repo_root / "hooks"
39
+ git_hooks_dir = self.repo_root / ".git" / "hooks"
40
+
41
+ if not hooks_dir.exists():
42
+ print_error(f"Hooks directory not found: {hooks_dir}")
43
+ return False
44
+
45
+ if not git_hooks_dir.exists():
46
+ print_error("Git hooks directory missing")
47
+ return False
48
+
49
+ post_commit_src = hooks_dir / "post-commit"
50
+ if not post_commit_src.exists():
51
+ print_error(f"Source hook not found: {post_commit_src}")
52
+ return False
53
+
54
+ # Copy and make executable
55
+ for hook_name in ["post-commit", "post-checkout", "post-merge"]:
56
+ dest = git_hooks_dir / hook_name
57
+
58
+ with open(post_commit_src, "r") as src:
59
+ with open(dest, "w") as dst:
60
+ dst.write(src.read())
61
+
62
+ dest.chmod(0o755)
63
+ print_success(f"Installed hook: {hook_name}")
64
+
65
+ # Run git checkout to trigger hooks
66
+ subprocess.run(["git", "checkout"], check=True, cwd=self.repo_root)
67
+
68
+ # Check for gitHeadInfo.gin
69
+ git_head_info = self.repo_root / ".git" / "gitHeadInfo.gin"
70
+ if not git_head_info.exists():
71
+ print_warning("gitHeadInfo.gin not found, skipping local copy")
72
+ else:
73
+ local_copy = self.repo_root / "gitHeadLocal.gin"
74
+ with open(git_head_info, "r") as src:
75
+ with open(local_copy, "w") as dst:
76
+ dst.write(src.read())
77
+
78
+ subprocess.run(
79
+ ["git", "add", "gitHeadLocal.gin"], check=True, cwd=self.repo_root
80
+ )
81
+ subprocess.run(
82
+ [
83
+ "git",
84
+ "commit",
85
+ "-m",
86
+ "Created gitHeadLocal.gin for initial setup",
87
+ ],
88
+ check=True,
89
+ cwd=self.repo_root,
90
+ )
91
+ print_success("Created and committed gitHeadLocal.gin")
92
+
93
+ return True
94
+
95
+ except subprocess.CalledProcessError as e:
96
+ print_error(f"Git command failed: {e}")
97
+ return False
98
+ except Exception as e:
99
+ print_error(f"Failed to setup hooks: {e}")
100
+ return False
101
+
102
+ def create_release(self, version: str, auto_push: bool = False) -> bool:
103
+ """
104
+ Create a new release with the given version
105
+
106
+ Args:
107
+ version: Version string (e.g., 'v1.0.0')
108
+ auto_push: Whether to automatically push the release
109
+
110
+ Returns:
111
+ True if successful, False otherwise
112
+ """
113
+ # Validate version format
114
+ if not re.match(r"^v\d+\.\d+\.\d+(-[a-z]+\.\d+)?$", version):
115
+ print_error(f"Invalid version format: {version}")
116
+ print_info("Expected format: vX.Y.Z or vX.Y.Z-pre.N")
117
+ return False
118
+
119
+ try:
120
+ # Check if tag exists
121
+ result = subprocess.run(
122
+ ["git", "rev-parse", version], capture_output=True, cwd=self.repo_root
123
+ )
124
+ if result.returncode == 0:
125
+ print_error(f"Tag {version} already exists")
126
+ return False
127
+
128
+ # Create tag
129
+ subprocess.run(
130
+ ["git", "tag", "-a", version, "-m", f"Release {version}"],
131
+ check=True,
132
+ cwd=self.repo_root,
133
+ )
134
+ print_success(f"Created tag: {version}")
135
+
136
+ # Trigger hooks
137
+ subprocess.run(["git", "checkout"], check=True, cwd=self.repo_root)
138
+
139
+ # Copy gitHeadInfo
140
+ git_head_info = self.repo_root / ".git" / "gitHeadInfo.gin"
141
+ if git_head_info.exists():
142
+ local_copy = self.repo_root / "gitHeadLocal.gin"
143
+ with open(git_head_info, "r") as src:
144
+ content = src.read()
145
+ with open(local_copy, "w") as dst:
146
+ dst.write(content)
147
+
148
+ # Show reltag
149
+ for line in content.split("\n"):
150
+ if "reltag" in line:
151
+ print_info(f"Release tag: {line}")
152
+
153
+ subprocess.run(
154
+ ["git", "add", "gitHeadLocal.gin"], check=True, cwd=self.repo_root
155
+ )
156
+ subprocess.run(
157
+ [
158
+ "git",
159
+ "commit",
160
+ "-m",
161
+ f"Updated gitHeadLocal.gin for release {version}",
162
+ ],
163
+ check=True,
164
+ cwd=self.repo_root,
165
+ )
166
+ subprocess.run(
167
+ ["git", "tag", "-f", "-a", version, "-m", f"Release {version}"],
168
+ check=True,
169
+ cwd=self.repo_root,
170
+ )
171
+
172
+ print_success(f"Release {version} created successfully")
173
+
174
+ if auto_push:
175
+ try:
176
+ subprocess.run(
177
+ ["git", "push", "origin", "--follow-tags"],
178
+ check=True,
179
+ cwd=self.repo_root,
180
+ )
181
+ print_success("Release pushed to remote")
182
+ except subprocess.CalledProcessError as e:
183
+ print_warning(f"Failed to push release: {e}")
184
+ print_info("Push manually with: git push origin --follow-tags")
185
+ else:
186
+ print_info("Push with: git push origin --follow-tags")
187
+
188
+ return True
189
+
190
+ except subprocess.CalledProcessError as e:
191
+ print_error(f"Git command failed: {e}")
192
+ return False
193
+ except Exception as e:
194
+ print_error(f"Failed to create release: {e}")
195
+ return False
196
+
197
+ def list_releases(self, count: int = 5) -> bool:
198
+ """
199
+ List recent releases
200
+
201
+ Args:
202
+ count: Number of releases to show
203
+
204
+ Returns:
205
+ True if successful, False otherwise
206
+ """
207
+ try:
208
+ result = subprocess.run(
209
+ ["git", "tag", "--sort=-creatordate"],
210
+ capture_output=True,
211
+ text=True,
212
+ check=True,
213
+ cwd=self.repo_root,
214
+ )
215
+
216
+ tags = result.stdout.strip().split("\n")
217
+ if not tags or tags == [""]:
218
+ print_info("No releases found")
219
+ return True
220
+
221
+ print(f"\n{Colors.BOLD}Recent releases:{Colors.ENDC}")
222
+ for i, tag in enumerate(tags[:count], 1):
223
+ print(f" {i}. {tag}")
224
+
225
+ if len(tags) > count:
226
+ print(f" ... and {len(tags) - count} more")
227
+
228
+ return True
229
+
230
+ except subprocess.CalledProcessError as e:
231
+ print_error(f"Failed to list releases: {e}")
232
+ return False
233
+
234
+ def delete_release(self, version: str, delete_remote: bool = False) -> bool:
235
+ """
236
+ Delete a release tag
237
+
238
+ Args:
239
+ version: Version tag to delete
240
+ delete_remote: Whether to also delete from remote
241
+
242
+ Returns:
243
+ True if successful, False otherwise
244
+ """
245
+ try:
246
+ subprocess.run(
247
+ ["git", "tag", "-d", version], check=True, cwd=self.repo_root
248
+ )
249
+ print_success(f"Deleted local tag: {version}")
250
+
251
+ if delete_remote:
252
+ try:
253
+ subprocess.run(
254
+ ["git", "push", "origin", "--delete", version],
255
+ check=True,
256
+ cwd=self.repo_root,
257
+ )
258
+ print_success(f"Deleted remote tag: {version}")
259
+ except subprocess.CalledProcessError as e:
260
+ print_warning(f"Failed to delete remote tag: {e}")
261
+ print_info(
262
+ f"Delete manually with: git push origin --delete {version}"
263
+ )
264
+ else:
265
+ print_info(f"To delete remote: git push origin --delete {version}")
266
+
267
+ return True
268
+
269
+ except subprocess.CalledProcessError as e:
270
+ print_error(f"Failed to delete tag: {e}")
271
+ return False
272
+
273
+ def clean_latex_files(self, extensions: Optional[List[str]] = None) -> bool:
274
+ """
275
+ Clean LaTeX build files
276
+
277
+ Args:
278
+ extensions: List of file extensions to clean (defaults to common LaTeX files)
279
+
280
+ Returns:
281
+ True if successful, False otherwise
282
+ """
283
+ if extensions is None:
284
+ extensions = [
285
+ ".aux",
286
+ ".bbl",
287
+ ".blg",
288
+ ".log",
289
+ ".out",
290
+ ".pyg",
291
+ ".fls",
292
+ ".synctex.gz",
293
+ ".toc",
294
+ ".fdb_latexmk",
295
+ ".idx",
296
+ ".ilg",
297
+ ".ind",
298
+ ".chl",
299
+ ".lof",
300
+ ".lot",
301
+ ]
302
+
303
+ removed_count = 0
304
+
305
+ # Remove files by extension
306
+ for ext in extensions:
307
+ for file in self.repo_root.glob(f"*{ext}"):
308
+ try:
309
+ file.unlink()
310
+ removed_count += 1
311
+ except Exception as e:
312
+ print_warning(f"Could not remove {file}: {e}")
313
+
314
+ # Remove _minted directories
315
+ for minted_dir in self.repo_root.glob("_minted-*"):
316
+ if minted_dir.is_dir():
317
+ try:
318
+ shutil.rmtree(minted_dir)
319
+ removed_count += 1
320
+ except Exception as e:
321
+ print_warning(f"Could not remove {minted_dir}: {e}")
322
+
323
+ if removed_count > 0:
324
+ print_success(f"Removed {removed_count} build file(s)")
325
+ else:
326
+ print_info("No build files to clean")
327
+
328
+ return True
329
+
330
+ def get_current_branch(self) -> Optional[str]:
331
+ """Get the current git branch name"""
332
+ try:
333
+ result = subprocess.run(
334
+ ["git", "branch", "--show-current"],
335
+ capture_output=True,
336
+ text=True,
337
+ check=True,
338
+ cwd=self.repo_root,
339
+ )
340
+ return result.stdout.strip()
341
+ except subprocess.CalledProcessError:
342
+ return None
343
+
344
+ def is_clean_working_directory(self) -> bool:
345
+ """Check if the working directory is clean (no uncommitted changes)"""
346
+ try:
347
+ result = subprocess.run(
348
+ ["git", "status", "--porcelain"],
349
+ capture_output=True,
350
+ text=True,
351
+ check=True,
352
+ cwd=self.repo_root,
353
+ )
354
+ return len(result.stdout.strip()) == 0
355
+ except subprocess.CalledProcessError:
356
+ return False