tgit 0.11.2__tar.gz → 0.13.0__tar.gz

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,35 @@
1
+ name: Build Python package with uv
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+
14
+ steps:
15
+ - name: Checkout code
16
+ uses: actions/checkout@v4
17
+
18
+ - name: Set up uv
19
+ uses: astral-sh/setup-uv@v6
20
+ with:
21
+ python-version: "3.12"
22
+ activate-environment: true
23
+ - name: Install dependencies
24
+ run: |
25
+ uv sync
26
+
27
+ - name: Build package (wheel and sdist)
28
+ run: |
29
+ python -m build
30
+
31
+ - name: Upload build artifacts
32
+ uses: actions/upload-artifact@v4
33
+ with:
34
+ name: dist-packages
35
+ path: dist/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tgit
3
- Version: 0.11.2
3
+ Version: 0.13.0
4
4
  Summary: Tool for Git Interaction Temptation (tgit): An elegant CLI tool that simplifies and streamlines your Git workflow, making version control a breeze.
5
5
  Author-email: Jannchie <jannchie@gmail.com>
6
6
  License-Expression: MIT
@@ -18,7 +18,6 @@ Requires-Dist: gitpython>=3.1.43
18
18
  Requires-Dist: gitpython>=3.1.44
19
19
  Requires-Dist: inquirer>=3.4.0
20
20
  Requires-Dist: jinja2>=3.1.4
21
- Requires-Dist: litellm>=1.59.9
22
21
  Requires-Dist: openai>=1.52.0
23
22
  Requires-Dist: pyyaml>=6.0.2
24
23
  Requires-Dist: rich>=13.9.4
@@ -1,65 +1,65 @@
1
- [project]
2
- name = "tgit"
3
- version = "0.11.2"
4
- description = "Tool for Git Interaction Temptation (tgit): An elegant CLI tool that simplifies and streamlines your Git workflow, making version control a breeze."
5
- authors = [{ name = "Jannchie", email = "jannchie@gmail.com" }]
6
- dependencies = [
7
- "rich>=13.9.4",
8
- "pyyaml>=6.0.2",
9
- "inquirer>=3.4.0",
10
- "gitpython>=3.1.43",
11
- "openai>=1.52.0",
12
- "jinja2>=3.1.4",
13
- "litellm>=1.59.9",
14
- "gitpython>=3.1.44",
15
- "inquirer>=3.4.0",
16
- "beautifulsoup4>=4.13.3",
17
- ]
18
- readme = { content-type = "text/markdown", file = "README.md" }
19
- requires-python = ">= 3.11"
20
- classifiers = [
21
- "Development Status :: 3 - Alpha",
22
- "Intended Audience :: Developers",
23
- "License :: OSI Approved :: MIT License",
24
- "Programming Language :: Python :: 3",
25
- "Topic :: Software Development :: Version Control",
26
- "Topic :: Utilities",
27
- "Typing :: Typed",
28
- ]
29
- keywords = ["git", "tool", "changelog", "version", "commit"]
30
- license = "MIT"
31
-
32
- [build-system]
33
- requires = ["hatchling"]
34
- build-backend = "hatchling.build"
35
-
36
- [tool.uv]
37
- upgrade = true
38
-
39
- [tool.hatch.metadata]
40
- allow-direct-references = true
41
-
42
- [tool.hatch.build.targets.wheel]
43
- packages = ["tgit"]
44
-
45
- [project.scripts]
46
- tgit = "tgit:cli.main"
47
-
48
- [tool.ruff]
49
- line-length = 140
50
- select = ["ALL"]
51
-
52
- ignore = [
53
- "PGH",
54
- "RUF003",
55
- "BLE001",
56
- "ERA001",
57
- "FIX002",
58
- "TD002",
59
- "TD003",
60
- "D",
61
- "TRY300",
62
- ]
63
-
64
- [dependency-groups]
65
- dev = ["ruff>=0.9.4"]
1
+ [project]
2
+ name = "tgit"
3
+ version = "0.13.0"
4
+ description = "Tool for Git Interaction Temptation (tgit): An elegant CLI tool that simplifies and streamlines your Git workflow, making version control a breeze."
5
+ authors = [{ name = "Jannchie", email = "jannchie@gmail.com" }]
6
+ dependencies = [
7
+ "rich>=13.9.4",
8
+ "pyyaml>=6.0.2",
9
+ "inquirer>=3.4.0",
10
+ "gitpython>=3.1.43",
11
+ "openai>=1.52.0",
12
+ "jinja2>=3.1.4",
13
+ "gitpython>=3.1.44",
14
+ "inquirer>=3.4.0",
15
+ "beautifulsoup4>=4.13.3",
16
+ ]
17
+ readme = { content-type = "text/markdown", file = "README.md" }
18
+ requires-python = ">= 3.11"
19
+ classifiers = [
20
+ "Development Status :: 3 - Alpha",
21
+ "Intended Audience :: Developers",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Programming Language :: Python :: 3",
24
+ "Topic :: Software Development :: Version Control",
25
+ "Topic :: Utilities",
26
+ "Typing :: Typed",
27
+ ]
28
+ keywords = ["git", "tool", "changelog", "version", "commit"]
29
+ license = "MIT"
30
+
31
+ [build-system]
32
+ requires = ["hatchling"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.uv]
36
+ upgrade = true
37
+ package = true
38
+
39
+ [tool.hatch.metadata]
40
+ allow-direct-references = true
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["tgit"]
44
+
45
+ [project.scripts]
46
+ tgit = "tgit:cli.main"
47
+
48
+ [tool.ruff]
49
+ line-length = 140
50
+ select = ["ALL"]
51
+
52
+ ignore = [
53
+ "PGH",
54
+ "RUF003",
55
+ "BLE001",
56
+ "ERA001",
57
+ "FIX002",
58
+ "TD002",
59
+ "TD003",
60
+ "D",
61
+ "TRY300",
62
+ ]
63
+
64
+ [dependency-groups]
65
+ dev = ["ruff>=0.9.4"]
@@ -7,6 +7,7 @@ from dataclasses import dataclass
7
7
  from pathlib import Path
8
8
 
9
9
  import git
10
+ from rich.progress import Progress
10
11
 
11
12
  logger = logging.getLogger("tgit")
12
13
 
@@ -232,26 +233,52 @@ def generate_changelog(commits_by_type: dict[str, list[TGITCommit]], from_ref: s
232
233
  def handle_changelog(args: ChangelogArgs) -> None:
233
234
  repo = git.Repo(args.path)
234
235
 
235
- if args.output:
236
- with Path(args.output).open("w") as output_file:
237
- # 获取所有 tags
238
- tags = repo.tags
239
- # 获取第一个 commit
240
- first_commit = get_first_commit_hash(repo)
241
- points = [first_commit] + [tag.name for tag in tags]
242
- points.reverse()
243
- changelogs = ""
244
- for i in range(len(points) - 1):
245
- to_ref = points[i]
246
- from_ref = points[i + 1]
247
- changelog = get_changelog_by_range(repo, from_ref, to_ref)
248
- changelogs += changelog
249
- output_file.write(changelogs.strip("\n") + "\n")
250
236
  from_raw = args.from_raw
251
237
  to_raw = args.to_raw
252
238
 
239
+ # 如果未指定 from/to,聚合所有 tag 版本的 changelog,优化性能
240
+ if from_raw is None and to_raw is None:
241
+ tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime)
242
+ first_commit = get_first_commit_hash(repo)
243
+ points = [first_commit] + [tag.commit.hexsha for tag in tags] + [repo.head.commit.hexsha]
244
+ point_names = [first_commit] + [tag.name for tag in tags] + ["HEAD"]
245
+ changelogs = ""
246
+ with Progress() as progress:
247
+ task = progress.add_task("Generating changelog...", total=len(points) - 1)
248
+ # 反向遍历区间,实现反向输出
249
+ for i in reversed(range(len(points) - 1)):
250
+ from_hash = points[i]
251
+ to_hash = points[i + 1]
252
+ from_name = point_names[i]
253
+ to_name = point_names[i + 1]
254
+ raw_commits = list(repo.iter_commits(f"{from_hash}...{to_hash}"))
255
+ tgit_commits = []
256
+ for commit in raw_commits:
257
+ if m := commit_pattern.match(commit.message):
258
+ message_dict = m.groupdict()
259
+ tgit_commits.append(TGITCommit(repo, commit, message_dict))
260
+ commits_by_type = group_commits_by_type(tgit_commits)
261
+ try:
262
+ origin_url = repo.remote().url
263
+ remote_uri = get_remote_uri(origin_url)
264
+ except ValueError:
265
+ remote_uri = None
266
+ changelog = generate_changelog(commits_by_type, from_name, to_name, remote_uri)
267
+ changelogs += changelog
268
+ progress.update(task, advance=1)
269
+ if args.output:
270
+ with Path(args.output).open("w") as output_file:
271
+ output_file.write(changelogs.strip("\n") + "\n")
272
+ print() # noqa: T201
273
+ print(changelogs.strip("\n")) # noqa: T201
274
+ return
275
+
276
+ # 否则输出指定范围的 changelog
253
277
  from_ref, to_ref = get_git_commits_range(repo, from_raw, to_raw)
254
278
  changelog = get_changelog_by_range(repo, from_ref, to_ref)
279
+ if args.output:
280
+ with Path(args.output).open("w") as output_file:
281
+ output_file.write(changelog.strip("\n") + "\n")
255
282
  print() # noqa: T201
256
283
  print(changelog) # noqa: T201
257
284
 
@@ -1,5 +1,6 @@
1
1
  import argparse
2
2
  import importlib.metadata
3
+ import threading
3
4
 
4
5
  import rich
5
6
  import rich.traceback
@@ -30,6 +31,12 @@ def main() -> None:
30
31
  define_config_parser(subparsers)
31
32
 
32
33
  args = parser.parse_args()
34
+
35
+ def import_openai() -> None:
36
+ import openai # noqa: F401
37
+
38
+ threading.Thread(target=import_openai).start()
39
+
33
40
  handle(parser, args)
34
41
 
35
42
 
@@ -7,11 +7,15 @@ from pathlib import Path
7
7
  import git
8
8
  from jinja2 import Environment, FileSystemLoader
9
9
  from pydantic import BaseModel
10
- from rich import print # noqa: A004
10
+ from rich import (
11
+ get_console,
12
+ print, # noqa: A004
13
+ )
11
14
 
12
15
  from tgit.settings import settings
13
16
  from tgit.utils import get_commit_command, run_command, type_emojis
14
17
 
18
+ console = get_console()
15
19
  with importlib.resources.path("tgit", "prompts") as prompt_path:
16
20
  env = Environment(loader=FileSystemLoader(prompt_path), autoescape=True)
17
21
 
@@ -19,6 +23,9 @@ commit_types = ["feat", "fix", "chore", "docs", "style", "refactor", "perf", "wi
19
23
  commit_file = "commit.txt"
20
24
  commit_prompt_template = env.get_template("commit.txt")
21
25
 
26
+ MAX_DIFF_LINES = 1000
27
+ NUMSTAT_PARTS = 3
28
+
22
29
 
23
30
  def define_commit_parser(subparsers: argparse._SubParsersAction) -> None:
24
31
  commit_type = ["feat", "fix", "chore", "docs", "style", "refactor", "perf"]
@@ -56,40 +63,85 @@ class CommitData(BaseModel):
56
63
  is_breaking: bool
57
64
 
58
65
 
59
- def get_ai_command() -> str | None:
66
+ def get_filtered_diff_files(repo: git.Repo) -> tuple[list[str], list[str]]:
67
+ diff_numstat = repo.git.diff("--cached", "--numstat")
68
+ files_to_include = []
69
+ lock_files = []
70
+ for line in diff_numstat.splitlines():
71
+ parts = line.split("\t")
72
+ if len(parts) >= NUMSTAT_PARTS:
73
+ added, deleted, filename = parts[0], parts[1], parts[2]
74
+ if filename.endswith(".lock"):
75
+ lock_files.append(filename)
76
+ continue
77
+ try:
78
+ added = int(added) if added != "-" else 0
79
+ deleted = int(deleted) if deleted != "-" else 0
80
+ except ValueError:
81
+ continue
82
+ if added + deleted <= MAX_DIFF_LINES:
83
+ files_to_include.append(filename)
84
+ return files_to_include, lock_files
85
+
86
+
87
+ def get_ai_command(specified_type: str | None = None) -> str | None:
60
88
  current_dir = Path.cwd()
61
89
  try:
62
90
  repo = git.Repo(current_dir, search_parent_directories=True)
63
91
  except git.InvalidGitRepositoryError:
64
92
  print("[yellow]Not a git repository[/yellow]")
65
93
  return None
66
- diff = repo.git.diff("--cached")
94
+ files_to_include, lock_files = get_filtered_diff_files(repo)
95
+ if not files_to_include and not lock_files:
96
+ print(f"[yellow]No files with diff lines <= {MAX_DIFF_LINES} to commit[/yellow]")
97
+ return None
98
+ diff = ""
99
+ if lock_files:
100
+ diff += f"[INFO] The following lock files were modified but are not included in the diff: {', '.join(lock_files)}\n"
101
+ if files_to_include:
102
+ diff += repo.git.diff("--cached", "--", *files_to_include)
103
+ current_branch = repo.active_branch.name
104
+
67
105
  if not diff:
68
106
  print("[yellow]No changes to commit, please add some changes before using AI[/yellow]")
69
107
  return None
70
108
  try:
71
- from litellm import completion
72
-
73
- chat_completion = completion(
74
- messages=[
75
- {
76
- "role": "system",
77
- "content": commit_prompt_template.render(types=commit_types),
78
- },
79
- {"role": "user", "content": diff},
80
- ],
81
- model=settings.get("model", "openai/gpt-4o"),
82
- api_key=settings.get("apiKey", None),
83
- base_url=settings.get("apiUrl", None),
84
- max_tokens=200,
85
- response_format=CommitData,
86
- )
87
- except Exception:
109
+ import openai
110
+
111
+ client = openai.Client()
112
+ if settings.get("apiUrl", None):
113
+ client.api_base = settings.get("apiUrl", None)
114
+ if settings.get("apiKey", None):
115
+ client.api_key = settings.get("apiKey", None)
116
+ # 准备模板渲染参数,如果用户指定了类型,则传递给模板
117
+ template_params = {"types": commit_types, "branch": current_branch}
118
+
119
+ if specified_type:
120
+ template_params["specified_type"] = specified_type
121
+ with console.status("[bold green]Generating commit message...[/bold green]"):
122
+ chat_completion = client.responses.parse(
123
+ input=[
124
+ {
125
+ "role": "system",
126
+ "content": commit_prompt_template.render(**template_params),
127
+ },
128
+ {"role": "user", "content": diff},
129
+ ],
130
+ model=settings.get("model", "gpt-4.1"),
131
+ max_output_tokens=50,
132
+ text_format=CommitData,
133
+ )
134
+ except Exception as e:
88
135
  print("[red]Could not connect to AI provider[/red]")
136
+ print(e)
89
137
  return None
90
- resp = CommitData.model_validate_json(chat_completion.choices[0].message.content)
138
+ resp = chat_completion.output_parsed
139
+
140
+ # 如果用户指定了类型,则使用用户指定的类型
141
+ commit_type = specified_type or resp.type
142
+
91
143
  return get_commit_command(
92
- resp.type,
144
+ commit_type,
93
145
  resp.scope,
94
146
  resp.msg,
95
147
  use_emoji=settings.get("commit", {}).get("emoji", False),
@@ -101,15 +153,26 @@ def handle_commit(args: CommitArgs) -> None:
101
153
  prefix = ["", "!"]
102
154
  choices = ["".join(data) for data in itertools.product(commit_types, prefix)] + ["ci", "test", "version"]
103
155
 
104
- if args.ai:
156
+ if args.ai or len(args.message) == 0:
157
+ # 如果明确指定使用 AI
105
158
  command = get_ai_command()
106
159
  if not command:
107
160
  return
161
+ elif len(args.message) == 1:
162
+ # 如果只提供了一个参数(只有类型)
163
+ commit_type = args.message[0]
164
+ if commit_type not in choices:
165
+ print(f"Invalid type: {commit_type}")
166
+ print(f"Valid types: {choices}")
167
+ return
168
+
169
+ # 使用 AI 生成提交信息,但保留用户指定的类型
170
+ command = get_ai_command(specified_type=commit_type)
171
+ if not command:
172
+ return
108
173
  else:
174
+ # 正常的提交流程
109
175
  messages = args.message
110
- if len(messages) == 0:
111
- print("Please provide a commit message, or use --ai to generate by AI")
112
- return
113
176
  commit_type = messages[0]
114
177
  if len(messages) > 2: # noqa: PLR2004
115
178
  commit_scope = messages[1]
@@ -126,4 +189,5 @@ def handle_commit(args: CommitArgs) -> None:
126
189
  use_emoji = settings.get("commit", {}).get("emoji", False)
127
190
  is_breaking = args.breaking
128
191
  command = get_commit_command(commit_type, commit_scope, commit_msg, use_emoji=use_emoji, is_breaking=is_breaking)
192
+
129
193
  run_command(command)
@@ -1,8 +1,11 @@
1
1
  You are a git bot. You should read the diff and suggest a commit message.
2
2
 
3
3
  Type:
4
-
4
+ {% if specified_type is defined %}
5
+ The user has specified the commit type as "{{ specified_type }}". You MUST honor this choice and generate a commit message that is appropriate for a "{{ specified_type }}" type of change.
6
+ {% else %}
5
7
  The type should be one of {{ types | join(', ') }}.
8
+ {% endif %}
6
9
 
7
10
  Scope:
8
11
 
@@ -25,6 +28,12 @@ Additional Considerations:
25
28
  Search the name and instruction online for the copyright content check.
26
29
  Make sure to search the name and instruction online for the copyright content check.
27
30
 
31
+ The Current Branch is {{ branch }}. You should referrence the branch name.
32
+
33
+ {% if specified_type is defined %}
34
+ IMPORTANT: The user has explicitly chosen "{{ specified_type }}" as the commit type. Your response MUST use this type. Do not change it.
35
+ {% endif %}
36
+
28
37
  The Json format you return should be parsed to the following class:
29
38
 
30
39
  ```