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.
- article_cli/__init__.py +20 -0
- article_cli/__main__.py +8 -0
- article_cli/cli.py +308 -0
- article_cli/config.py +275 -0
- article_cli/git_manager.py +356 -0
- article_cli/zotero.py +359 -0
- article_cli-1.0.0.dist-info/METADATA +215 -0
- article_cli-1.0.0.dist-info/RECORD +11 -0
- article_cli-1.0.0.dist-info/WHEEL +4 -0
- article_cli-1.0.0.dist-info/entry_points.txt +2 -0
- article_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|