gitwit 0.1.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.
gitwit/git.py ADDED
@@ -0,0 +1,312 @@
1
+ """Git operations for GitWit."""
2
+
3
+ import subprocess
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ class GitError(Exception):
9
+ """Exception raised for git-related errors."""
10
+ pass
11
+
12
+
13
+ class NotAGitRepoError(GitError):
14
+ """Exception raised when not in a git repository."""
15
+ pass
16
+
17
+
18
+ class NoStagedChangesError(GitError):
19
+ """Exception raised when there are no staged changes."""
20
+ pass
21
+
22
+
23
+ @dataclass
24
+ class DiffStats:
25
+ """Statistics about a git diff."""
26
+ files_changed: list[str]
27
+ additions: int
28
+ deletions: int
29
+ total_lines: int
30
+
31
+
32
+ def run_git_command(args: list[str], cwd: Path | None = None) -> str:
33
+ """
34
+ Run a git command and return its output.
35
+
36
+ Args:
37
+ args: List of arguments to pass to git.
38
+ cwd: Working directory for the command.
39
+
40
+ Returns:
41
+ The command output as a string.
42
+
43
+ Raises:
44
+ GitError: If the command fails.
45
+ NotAGitRepoError: If not in a git repository.
46
+ """
47
+ try:
48
+ result = subprocess.run(
49
+ ["git"] + args,
50
+ capture_output=True,
51
+ text=True,
52
+ cwd=cwd,
53
+ timeout=30,
54
+ )
55
+
56
+ if result.returncode != 0:
57
+ stderr = result.stderr.strip()
58
+ if "not a git repository" in stderr.lower():
59
+ raise NotAGitRepoError("Not a git repository. Run 'git init' first.")
60
+ raise GitError(f"Git command failed: {stderr}")
61
+
62
+ return result.stdout
63
+
64
+ except subprocess.TimeoutExpired:
65
+ raise GitError("Git command timed out")
66
+ except FileNotFoundError:
67
+ raise GitError("Git is not installed or not in PATH")
68
+
69
+
70
+ def is_git_repo(path: Path | None = None) -> bool:
71
+ """
72
+ Check if the current directory is a git repository.
73
+
74
+ Args:
75
+ path: Path to check. Defaults to current directory.
76
+
77
+ Returns:
78
+ True if in a git repository, False otherwise.
79
+ """
80
+ try:
81
+ run_git_command(["rev-parse", "--git-dir"], cwd=path)
82
+ return True
83
+ except GitError:
84
+ return False
85
+
86
+
87
+ def get_repo_root(path: Path | None = None) -> Path:
88
+ """
89
+ Get the root directory of the git repository.
90
+
91
+ Args:
92
+ path: Path to start from. Defaults to current directory.
93
+
94
+ Returns:
95
+ Path to the repository root.
96
+
97
+ Raises:
98
+ NotAGitRepoError: If not in a git repository.
99
+ """
100
+ output = run_git_command(["rev-parse", "--show-toplevel"], cwd=path)
101
+ return Path(output.strip())
102
+
103
+
104
+ def get_staged_diff(path: Path | None = None) -> str:
105
+ """
106
+ Get the diff of staged changes.
107
+
108
+ Args:
109
+ path: Repository path. Defaults to current directory.
110
+
111
+ Returns:
112
+ The staged diff as a string.
113
+
114
+ Raises:
115
+ NoStagedChangesError: If there are no staged changes.
116
+ """
117
+ diff = run_git_command(["diff", "--cached"], cwd=path)
118
+
119
+ if not diff.strip():
120
+ raise NoStagedChangesError(
121
+ "No staged changes. Stage changes with 'git add' first, "
122
+ "or use 'gitwit commit --all' to stage all changes."
123
+ )
124
+
125
+ return diff
126
+
127
+
128
+ def get_staged_files(path: Path | None = None) -> list[str]:
129
+ """
130
+ Get a list of staged files.
131
+
132
+ Args:
133
+ path: Repository path. Defaults to current directory.
134
+
135
+ Returns:
136
+ List of staged file paths.
137
+ """
138
+ output = run_git_command(["diff", "--cached", "--name-only"], cwd=path)
139
+ return [f for f in output.strip().split("\n") if f]
140
+
141
+
142
+ def has_staged_changes(path: Path | None = None) -> bool:
143
+ """
144
+ Check if there are any staged changes.
145
+
146
+ Args:
147
+ path: Repository path. Defaults to current directory.
148
+
149
+ Returns:
150
+ True if there are staged changes, False otherwise.
151
+ """
152
+ try:
153
+ get_staged_diff(path)
154
+ return True
155
+ except NoStagedChangesError:
156
+ return False
157
+
158
+
159
+ def stage_all_changes(path: Path | None = None) -> None:
160
+ """
161
+ Stage all changes in the repository.
162
+
163
+ Args:
164
+ path: Repository path. Defaults to current directory.
165
+ """
166
+ run_git_command(["add", "-A"], cwd=path)
167
+
168
+
169
+ def commit(message: str, path: Path | None = None) -> str:
170
+ """
171
+ Create a commit with the given message.
172
+
173
+ Args:
174
+ message: Commit message.
175
+ path: Repository path. Defaults to current directory.
176
+
177
+ Returns:
178
+ The commit output.
179
+ """
180
+ return run_git_command(["commit", "-m", message], cwd=path)
181
+
182
+
183
+ def get_branch_name(path: Path | None = None) -> str:
184
+ """
185
+ Get the current branch name.
186
+
187
+ Args:
188
+ path: Repository path. Defaults to current directory.
189
+
190
+ Returns:
191
+ The current branch name.
192
+ """
193
+ output = run_git_command(["branch", "--show-current"], cwd=path)
194
+ return output.strip()
195
+
196
+
197
+ def get_branch_diff(base_branch: str = "main", path: Path | None = None) -> str:
198
+ """
199
+ Get the diff between the current branch and a base branch.
200
+
201
+ Args:
202
+ base_branch: The base branch to compare against.
203
+ path: Repository path. Defaults to current directory.
204
+
205
+ Returns:
206
+ The diff as a string.
207
+ """
208
+ # Try the specified branch, fall back to 'master' if 'main' doesn't exist
209
+ try:
210
+ return run_git_command(["diff", f"{base_branch}...HEAD"], cwd=path)
211
+ except GitError:
212
+ if base_branch == "main":
213
+ return run_git_command(["diff", "master...HEAD"], cwd=path)
214
+ raise
215
+
216
+
217
+ def parse_diff_stats(diff: str) -> DiffStats:
218
+ """
219
+ Parse a diff string and extract statistics.
220
+
221
+ Args:
222
+ diff: The git diff string.
223
+
224
+ Returns:
225
+ DiffStats with information about the changes.
226
+ """
227
+ lines = diff.split('\n')
228
+ files = []
229
+ additions = 0
230
+ deletions = 0
231
+
232
+ for line in lines:
233
+ if line.startswith('diff --git'):
234
+ # Extract filename from "diff --git a/path b/path"
235
+ parts = line.split(' ')
236
+ if len(parts) >= 4:
237
+ files.append(parts[2][2:]) # Remove 'a/' prefix
238
+ elif line.startswith('+') and not line.startswith('+++'):
239
+ additions += 1
240
+ elif line.startswith('-') and not line.startswith('---'):
241
+ deletions += 1
242
+
243
+ return DiffStats(
244
+ files_changed=files,
245
+ additions=additions,
246
+ deletions=deletions,
247
+ total_lines=len(lines)
248
+ )
249
+
250
+
251
+ def get_current_branch(path: Path | None = None) -> str:
252
+ """
253
+ Get the current branch name.
254
+
255
+ Args:
256
+ path: Repository path. Defaults to current directory.
257
+
258
+ Returns:
259
+ The current branch name.
260
+ """
261
+ return get_branch_name(path)
262
+
263
+
264
+ def get_default_branch(path: Path | None = None) -> str:
265
+ """
266
+ Detect the default branch (main or master).
267
+
268
+ Args:
269
+ path: Repository path. Defaults to current directory.
270
+
271
+ Returns:
272
+ 'main' if it exists, otherwise 'master'.
273
+
274
+ Raises:
275
+ GitError: If neither main nor master exists.
276
+ """
277
+ # Check if 'main' branch exists
278
+ try:
279
+ run_git_command(["rev-parse", "--verify", "main"], cwd=path)
280
+ return "main"
281
+ except GitError:
282
+ pass
283
+
284
+ # Check if 'master' branch exists
285
+ try:
286
+ run_git_command(["rev-parse", "--verify", "master"], cwd=path)
287
+ return "master"
288
+ except GitError:
289
+ pass
290
+
291
+ raise GitError("Could not find default branch (main or master)")
292
+
293
+
294
+ def get_recent_commits(count: int = 5, path: Path | None = None) -> list[str]:
295
+ """
296
+ Get recent commit messages for style reference.
297
+
298
+ Args:
299
+ count: Number of commits to retrieve.
300
+ path: Repository path. Defaults to current directory.
301
+
302
+ Returns:
303
+ List of recent commit messages.
304
+ """
305
+ try:
306
+ output = run_git_command(
307
+ ["log", f"-{count}", "--pretty=format:%s"],
308
+ cwd=path
309
+ )
310
+ return [msg for msg in output.strip().split("\n") if msg]
311
+ except GitError:
312
+ return []
gitwit/license.py ADDED
@@ -0,0 +1,124 @@
1
+ """License validation for GitWit (LemonSqueezy integration stub)."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class LicenseInfo:
9
+ """Information about a validated license."""
10
+ valid: bool
11
+ license_key: str | None = None
12
+ email: str | None = None
13
+ plan: str = "free" # free, pro, team
14
+ features: list[str] | None = None
15
+
16
+ def __post_init__(self):
17
+ if self.features is None:
18
+ self.features = self._get_plan_features()
19
+
20
+ def _get_plan_features(self) -> list[str]:
21
+ """Get features for the current plan."""
22
+ free_features = ["commit"]
23
+ pro_features = free_features + ["pr", "changelog", "release", "custom-prompts"]
24
+ team_features = pro_features + ["team-config", "priority-support"]
25
+
26
+ if self.plan == "team":
27
+ return team_features
28
+ elif self.plan == "pro":
29
+ return pro_features
30
+ return free_features
31
+
32
+ def has_feature(self, feature: str) -> bool:
33
+ """Check if a feature is available in the current plan."""
34
+ return feature in (self.features or [])
35
+
36
+
37
+ # Global license state
38
+ _current_license: LicenseInfo | None = None
39
+
40
+
41
+ def get_license() -> LicenseInfo:
42
+ """
43
+ Get the current license information.
44
+
45
+ Returns:
46
+ LicenseInfo with current license status.
47
+ """
48
+ global _current_license
49
+
50
+ if _current_license is None:
51
+ # For MVP, return free license
52
+ # TODO: Implement LemonSqueezy validation
53
+ _current_license = LicenseInfo(valid=True, plan="free")
54
+
55
+ return _current_license
56
+
57
+
58
+ def validate_license(license_key: str) -> LicenseInfo:
59
+ """
60
+ Validate a license key with LemonSqueezy.
61
+
62
+ Args:
63
+ license_key: The license key to validate.
64
+
65
+ Returns:
66
+ LicenseInfo with validation result.
67
+
68
+ Note:
69
+ This is a stub for MVP. Full implementation will call LemonSqueezy API.
70
+ """
71
+ global _current_license
72
+
73
+ # TODO: Implement actual LemonSqueezy API validation
74
+ # API endpoint: https://api.lemonsqueezy.com/v1/licenses/validate
75
+ #
76
+ # For now, accept any key that looks valid (for testing)
77
+ if license_key and len(license_key) >= 10:
78
+ _current_license = LicenseInfo(
79
+ valid=True,
80
+ license_key=license_key,
81
+ plan="pro",
82
+ )
83
+ else:
84
+ _current_license = LicenseInfo(valid=False, plan="free")
85
+
86
+ return _current_license
87
+
88
+
89
+ def require_feature(feature: str) -> bool:
90
+ """
91
+ Check if a feature is available, showing upgrade message if not.
92
+
93
+ Args:
94
+ feature: The feature to check.
95
+
96
+ Returns:
97
+ True if feature is available, False otherwise.
98
+ """
99
+ license_info = get_license()
100
+
101
+ if license_info.has_feature(feature):
102
+ return True
103
+
104
+ # Feature not available
105
+ return False
106
+
107
+
108
+ def get_upgrade_message(feature: str) -> str:
109
+ """Get the upgrade message for a feature."""
110
+ feature_names = {
111
+ "pr": "PR description generation",
112
+ "changelog": "Changelog generation",
113
+ "release": "Release notes generation",
114
+ "custom-prompts": "Custom prompt templates",
115
+ "team-config": "Team configuration",
116
+ }
117
+
118
+ feature_name = feature_names.get(feature, feature)
119
+
120
+ return (
121
+ f"\n{feature_name} is a Pro feature.\n\n"
122
+ f"Upgrade to GitWit Pro: https://gitwit.dev/pricing\n"
123
+ f"Use code LAUNCH50 for 50% off!\n"
124
+ )
gitwit/prompts.py ADDED
@@ -0,0 +1,161 @@
1
+ """Prompt templates for AI operations."""
2
+
3
+ COMMIT_SYSTEM_PROMPT = """You are a git commit message generator. Generate a single conventional commit message following the format:
4
+
5
+ type(scope): description
6
+
7
+ Rules:
8
+ - Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build
9
+ - Scope is optional but encouraged (e.g., auth, api, ui, config)
10
+ - Description must be lowercase, imperative mood, no period at end
11
+ - Keep the first line under 72 characters
12
+ - Only output the commit message, nothing else
13
+ - No quotes around the message
14
+ - No explanation or preamble
15
+
16
+ Examples of good commit messages:
17
+ - feat(auth): add OAuth2 login support
18
+ - fix(api): handle null response from user endpoint
19
+ - docs: update installation instructions
20
+ - refactor(utils): simplify date parsing logic
21
+ - chore: update dependencies to latest versions"""
22
+
23
+ COMMIT_USER_PROMPT = """Generate a commit message for the following git diff:
24
+
25
+ {diff}"""
26
+
27
+ COMMIT_SUMMARY_USER_PROMPT = """Generate a commit message for the following changes:
28
+
29
+ Files changed: {files}
30
+ Total additions: +{additions}
31
+ Total deletions: -{deletions}
32
+
33
+ Partial diff (truncated due to size):
34
+ {diff}"""
35
+
36
+
37
+ def get_commit_prompt(diff: str, max_diff_chars: int = 4000) -> tuple[str, str]:
38
+ """
39
+ Get the system and user prompts for commit message generation.
40
+
41
+ If the diff is too long, summarizes it to fit within token limits.
42
+
43
+ Args:
44
+ diff: The git diff string
45
+ max_diff_chars: Maximum characters for the diff in the prompt
46
+
47
+ Returns:
48
+ Tuple of (system_prompt, user_prompt)
49
+ """
50
+ if len(diff) <= max_diff_chars:
51
+ return COMMIT_SYSTEM_PROMPT, COMMIT_USER_PROMPT.format(diff=diff)
52
+
53
+ # Parse diff to get summary info
54
+ lines = diff.split('\n')
55
+ files = []
56
+ additions = 0
57
+ deletions = 0
58
+
59
+ for line in lines:
60
+ if line.startswith('diff --git'):
61
+ # Extract filename from "diff --git a/path b/path"
62
+ parts = line.split(' ')
63
+ if len(parts) >= 4:
64
+ files.append(parts[2][2:]) # Remove 'a/' prefix
65
+ elif line.startswith('+') and not line.startswith('+++'):
66
+ additions += 1
67
+ elif line.startswith('-') and not line.startswith('---'):
68
+ deletions += 1
69
+
70
+ # Truncate diff to fit
71
+ truncated_diff = diff[:max_diff_chars - 500] # Leave room for summary
72
+
73
+ user_prompt = COMMIT_SUMMARY_USER_PROMPT.format(
74
+ files=', '.join(files[:10]) + ('...' if len(files) > 10 else ''),
75
+ additions=additions,
76
+ deletions=deletions,
77
+ diff=truncated_diff
78
+ )
79
+
80
+ return COMMIT_SYSTEM_PROMPT, user_prompt
81
+
82
+
83
+ # PR (Pull Request) prompts
84
+
85
+ PR_SYSTEM_PROMPT = """You are a pull request description generator. Generate a PR title and description in the following exact format:
86
+
87
+ Title: <short descriptive title under 72 characters>
88
+
89
+ ## Summary
90
+ <1-3 sentences describing what this PR does and why>
91
+
92
+ ## Changes
93
+ <bullet list of key changes>
94
+
95
+ Rules:
96
+ - Title should be concise and descriptive, no type prefix needed
97
+ - Summary should explain the purpose and impact
98
+ - Changes should list the main modifications made
99
+ - Keep it professional and clear
100
+ - No code blocks unless absolutely necessary
101
+ - Output ONLY the formatted content, no extra text"""
102
+
103
+ PR_USER_PROMPT = """Generate a PR title and description for the following branch diff:
104
+
105
+ Branch: {branch_name}
106
+
107
+ {diff}"""
108
+
109
+ PR_SUMMARY_USER_PROMPT = """Generate a PR title and description for the following changes:
110
+
111
+ Branch: {branch_name}
112
+ Files changed: {files}
113
+ Total additions: +{additions}
114
+ Total deletions: -{deletions}
115
+
116
+ Partial diff (truncated due to size):
117
+ {diff}"""
118
+
119
+
120
+ def get_pr_prompt(diff: str, branch_name: str, max_diff_chars: int = 6000) -> tuple[str, str]:
121
+ """
122
+ Get the system and user prompts for PR description generation.
123
+
124
+ Args:
125
+ diff: The git diff string
126
+ branch_name: The current branch name
127
+ max_diff_chars: Maximum characters for the diff in the prompt
128
+
129
+ Returns:
130
+ Tuple of (system_prompt, user_prompt)
131
+ """
132
+ if len(diff) <= max_diff_chars:
133
+ return PR_SYSTEM_PROMPT, PR_USER_PROMPT.format(branch_name=branch_name, diff=diff)
134
+
135
+ # Parse diff to get summary info
136
+ lines = diff.split('\n')
137
+ files = []
138
+ additions = 0
139
+ deletions = 0
140
+
141
+ for line in lines:
142
+ if line.startswith('diff --git'):
143
+ parts = line.split(' ')
144
+ if len(parts) >= 4:
145
+ files.append(parts[2][2:])
146
+ elif line.startswith('+') and not line.startswith('+++'):
147
+ additions += 1
148
+ elif line.startswith('-') and not line.startswith('---'):
149
+ deletions += 1
150
+
151
+ truncated_diff = diff[:max_diff_chars - 500]
152
+
153
+ user_prompt = PR_SUMMARY_USER_PROMPT.format(
154
+ branch_name=branch_name,
155
+ files=', '.join(files[:10]) + ('...' if len(files) > 10 else ''),
156
+ additions=additions,
157
+ deletions=deletions,
158
+ diff=truncated_diff
159
+ )
160
+
161
+ return PR_SYSTEM_PROMPT, user_prompt