patch-chat 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.
Files changed (146) hide show
  1. patch/__init__.py +20 -0
  2. patch/__main__.py +4 -0
  3. patch/_version.py +24 -0
  4. patch/analytics.py +260 -0
  5. patch/args.py +955 -0
  6. patch/args_formatter.py +228 -0
  7. patch/coders/__init__.py +32 -0
  8. patch/coders/architect_coder.py +47 -0
  9. patch/coders/architect_prompts.py +40 -0
  10. patch/coders/ask_coder.py +9 -0
  11. patch/coders/ask_prompts.py +41 -0
  12. patch/coders/base_coder.py +2548 -0
  13. patch/coders/base_prompts.py +60 -0
  14. patch/coders/chat_chunks.py +64 -0
  15. patch/coders/context_coder.py +53 -0
  16. patch/coders/context_prompts.py +75 -0
  17. patch/coders/edit_apply.py +6 -0
  18. patch/coders/editblock_coder.py +620 -0
  19. patch/coders/editblock_fenced_coder.py +10 -0
  20. patch/coders/editblock_fenced_prompts.py +143 -0
  21. patch/coders/editblock_func_coder.py +141 -0
  22. patch/coders/editblock_func_prompts.py +27 -0
  23. patch/coders/editblock_prompts.py +172 -0
  24. patch/coders/editor_diff_fenced_coder.py +9 -0
  25. patch/coders/editor_diff_fenced_prompts.py +11 -0
  26. patch/coders/editor_editblock_coder.py +8 -0
  27. patch/coders/editor_editblock_prompts.py +18 -0
  28. patch/coders/editor_whole_coder.py +8 -0
  29. patch/coders/editor_whole_prompts.py +10 -0
  30. patch/coders/patch_coder.py +731 -0
  31. patch/coders/patch_prompts.py +159 -0
  32. patch/coders/search_replace.py +757 -0
  33. patch/coders/shell.py +37 -0
  34. patch/coders/single_wholefile_func_coder.py +102 -0
  35. patch/coders/single_wholefile_func_prompts.py +27 -0
  36. patch/coders/udiff_coder.py +419 -0
  37. patch/coders/udiff_prompts.py +113 -0
  38. patch/coders/udiff_simple.py +14 -0
  39. patch/coders/udiff_simple_prompts.py +25 -0
  40. patch/coders/wholefile_coder.py +144 -0
  41. patch/coders/wholefile_func_coder.py +134 -0
  42. patch/coders/wholefile_func_prompts.py +27 -0
  43. patch/coders/wholefile_prompts.py +64 -0
  44. patch/commands.py +1668 -0
  45. patch/copypaste.py +70 -0
  46. patch/deprecated.py +126 -0
  47. patch/diffs.py +128 -0
  48. patch/dump.py +29 -0
  49. patch/editor.py +147 -0
  50. patch/exceptions.py +113 -0
  51. patch/format_settings.py +47 -0
  52. patch/gui.py +545 -0
  53. patch/history.py +143 -0
  54. patch/io.py +1209 -0
  55. patch/linter.py +304 -0
  56. patch/llm.py +47 -0
  57. patch/main.py +1466 -0
  58. patch/mdstream.py +243 -0
  59. patch/models.py +1342 -0
  60. patch/onboarding.py +428 -0
  61. patch/openrouter.py +128 -0
  62. patch/prompts.py +61 -0
  63. patch/queries/tree-sitter-language-pack/README.md +7 -0
  64. patch/queries/tree-sitter-language-pack/arduino-tags.scm +5 -0
  65. patch/queries/tree-sitter-language-pack/bash-tags.scm +8 -0
  66. patch/queries/tree-sitter-language-pack/c-tags.scm +9 -0
  67. patch/queries/tree-sitter-language-pack/chatito-tags.scm +16 -0
  68. patch/queries/tree-sitter-language-pack/clojure-tags.scm +7 -0
  69. patch/queries/tree-sitter-language-pack/commonlisp-tags.scm +122 -0
  70. patch/queries/tree-sitter-language-pack/cpp-tags.scm +15 -0
  71. patch/queries/tree-sitter-language-pack/csharp-tags.scm +26 -0
  72. patch/queries/tree-sitter-language-pack/d-tags.scm +26 -0
  73. patch/queries/tree-sitter-language-pack/dart-tags.scm +92 -0
  74. patch/queries/tree-sitter-language-pack/elisp-tags.scm +5 -0
  75. patch/queries/tree-sitter-language-pack/elixir-tags.scm +54 -0
  76. patch/queries/tree-sitter-language-pack/elm-tags.scm +19 -0
  77. patch/queries/tree-sitter-language-pack/gleam-tags.scm +41 -0
  78. patch/queries/tree-sitter-language-pack/go-tags.scm +42 -0
  79. patch/queries/tree-sitter-language-pack/java-tags.scm +20 -0
  80. patch/queries/tree-sitter-language-pack/javascript-tags.scm +88 -0
  81. patch/queries/tree-sitter-language-pack/lua-tags.scm +34 -0
  82. patch/queries/tree-sitter-language-pack/matlab-tags.scm +10 -0
  83. patch/queries/tree-sitter-language-pack/ocaml-tags.scm +115 -0
  84. patch/queries/tree-sitter-language-pack/ocaml_interface-tags.scm +98 -0
  85. patch/queries/tree-sitter-language-pack/pony-tags.scm +39 -0
  86. patch/queries/tree-sitter-language-pack/properties-tags.scm +5 -0
  87. patch/queries/tree-sitter-language-pack/python-tags.scm +14 -0
  88. patch/queries/tree-sitter-language-pack/r-tags.scm +21 -0
  89. patch/queries/tree-sitter-language-pack/racket-tags.scm +12 -0
  90. patch/queries/tree-sitter-language-pack/ruby-tags.scm +64 -0
  91. patch/queries/tree-sitter-language-pack/rust-tags.scm +60 -0
  92. patch/queries/tree-sitter-language-pack/solidity-tags.scm +43 -0
  93. patch/queries/tree-sitter-language-pack/swift-tags.scm +51 -0
  94. patch/queries/tree-sitter-language-pack/udev-tags.scm +20 -0
  95. patch/queries/tree-sitter-languages/README.md +24 -0
  96. patch/queries/tree-sitter-languages/bash-tags.scm +8 -0
  97. patch/queries/tree-sitter-languages/c-tags.scm +9 -0
  98. patch/queries/tree-sitter-languages/c_sharp-tags.scm +46 -0
  99. patch/queries/tree-sitter-languages/cpp-tags.scm +15 -0
  100. patch/queries/tree-sitter-languages/dart-tags.scm +91 -0
  101. patch/queries/tree-sitter-languages/elisp-tags.scm +8 -0
  102. patch/queries/tree-sitter-languages/elixir-tags.scm +54 -0
  103. patch/queries/tree-sitter-languages/elm-tags.scm +19 -0
  104. patch/queries/tree-sitter-languages/fortran-tags.scm +15 -0
  105. patch/queries/tree-sitter-languages/go-tags.scm +30 -0
  106. patch/queries/tree-sitter-languages/haskell-tags.scm +3 -0
  107. patch/queries/tree-sitter-languages/hcl-tags.scm +77 -0
  108. patch/queries/tree-sitter-languages/java-tags.scm +20 -0
  109. patch/queries/tree-sitter-languages/javascript-tags.scm +88 -0
  110. patch/queries/tree-sitter-languages/julia-tags.scm +60 -0
  111. patch/queries/tree-sitter-languages/kotlin-tags.scm +27 -0
  112. patch/queries/tree-sitter-languages/matlab-tags.scm +10 -0
  113. patch/queries/tree-sitter-languages/ocaml-tags.scm +115 -0
  114. patch/queries/tree-sitter-languages/ocaml_interface-tags.scm +98 -0
  115. patch/queries/tree-sitter-languages/php-tags.scm +26 -0
  116. patch/queries/tree-sitter-languages/python-tags.scm +12 -0
  117. patch/queries/tree-sitter-languages/ql-tags.scm +26 -0
  118. patch/queries/tree-sitter-languages/ruby-tags.scm +64 -0
  119. patch/queries/tree-sitter-languages/rust-tags.scm +60 -0
  120. patch/queries/tree-sitter-languages/scala-tags.scm +65 -0
  121. patch/queries/tree-sitter-languages/typescript-tags.scm +41 -0
  122. patch/queries/tree-sitter-languages/zig-tags.scm +3 -0
  123. patch/reasoning_tags.py +82 -0
  124. patch/repo.py +612 -0
  125. patch/repomap.py +859 -0
  126. patch/report.py +200 -0
  127. patch/resources/__init__.py +3 -0
  128. patch/resources/model-metadata.json +715 -0
  129. patch/resources/model-settings.yml +3128 -0
  130. patch/run_cmd.py +146 -0
  131. patch/scrape.py +316 -0
  132. patch/sendchat.py +61 -0
  133. patch/special.py +203 -0
  134. patch/urls.py +17 -0
  135. patch/utils.py +365 -0
  136. patch/versioncheck.py +113 -0
  137. patch/voice.py +200 -0
  138. patch/waiting.py +222 -0
  139. patch/watch.py +318 -0
  140. patch/watch_prompts.py +12 -0
  141. patch_chat-0.1.0.dist-info/METADATA +375 -0
  142. patch_chat-0.1.0.dist-info/RECORD +146 -0
  143. patch_chat-0.1.0.dist-info/WHEEL +5 -0
  144. patch_chat-0.1.0.dist-info/entry_points.txt +2 -0
  145. patch_chat-0.1.0.dist-info/licenses/LICENSE.txt +202 -0
  146. patch_chat-0.1.0.dist-info/top_level.txt +1 -0
patch/repo.py ADDED
@@ -0,0 +1,612 @@
1
+ import contextlib
2
+ import os
3
+ import time
4
+ from pathlib import Path, PurePosixPath
5
+
6
+ try:
7
+ import git
8
+
9
+ ANY_GIT_ERROR = (
10
+ git.exc.ODBError,
11
+ git.exc.GitError,
12
+ git.exc.InvalidGitRepositoryError,
13
+ git.exc.GitCommandNotFound,
14
+ )
15
+ except ImportError:
16
+ git = None
17
+ ANY_GIT_ERROR = ()
18
+
19
+ import pathspec
20
+
21
+ from patch import prompts, utils
22
+
23
+ from .dump import dump # noqa: F401
24
+ from .waiting import WaitingSpinner
25
+
26
+
27
+ @contextlib.contextmanager
28
+ def set_git_env(var_name, value, original_value):
29
+ """Temporarily set a Git environment variable."""
30
+ os.environ[var_name] = value
31
+ try:
32
+ yield
33
+ finally:
34
+ if original_value is not None:
35
+ os.environ[var_name] = original_value
36
+ elif var_name in os.environ:
37
+ del os.environ[var_name]
38
+
39
+
40
+ class GitRepo:
41
+ repo = None
42
+ patch_ignore_file = None
43
+ patch_ignore_spec = None
44
+ patch_ignore_ts = 0
45
+ patch_ignore_last_check = 0
46
+ subtree_only = False
47
+ ignore_file_cache = {}
48
+ git_repo_error = None
49
+
50
+ def __init__(
51
+ self,
52
+ io,
53
+ fnames,
54
+ git_dname,
55
+ patch_ignore_file=None,
56
+ models=None,
57
+ attribute_author=True,
58
+ attribute_committer=True,
59
+ attribute_commit_message_author=False,
60
+ attribute_commit_message_committer=False,
61
+ commit_prompt=None,
62
+ subtree_only=False,
63
+ git_commit_verify=True,
64
+ attribute_co_authored_by=False, # Added parameter
65
+ ):
66
+ self.io = io
67
+ self.models = models
68
+
69
+ self.normalized_path = {}
70
+ self.tree_files = {}
71
+
72
+ self.attribute_author = attribute_author
73
+ self.attribute_committer = attribute_committer
74
+ self.attribute_commit_message_author = attribute_commit_message_author
75
+ self.attribute_commit_message_committer = attribute_commit_message_committer
76
+ self.attribute_co_authored_by = attribute_co_authored_by # Assign from parameter
77
+ self.commit_prompt = commit_prompt
78
+ self.subtree_only = subtree_only
79
+ self.git_commit_verify = git_commit_verify
80
+ self.ignore_file_cache = {}
81
+
82
+ if git_dname:
83
+ check_fnames = [git_dname]
84
+ elif fnames:
85
+ check_fnames = fnames
86
+ else:
87
+ check_fnames = ["."]
88
+
89
+ repo_paths = []
90
+ for fname in check_fnames:
91
+ fname = Path(fname)
92
+ fname = fname.resolve()
93
+
94
+ if not fname.exists() and fname.parent.exists():
95
+ fname = fname.parent
96
+
97
+ try:
98
+ repo_path = git.Repo(fname, search_parent_directories=True).working_dir
99
+ repo_path = utils.safe_abs_path(repo_path)
100
+ repo_paths.append(repo_path)
101
+ except ANY_GIT_ERROR:
102
+ pass
103
+
104
+ num_repos = len(set(repo_paths))
105
+
106
+ if num_repos == 0:
107
+ raise FileNotFoundError
108
+ if num_repos > 1:
109
+ self.io.tool_error("Files are in different git repos.")
110
+ raise FileNotFoundError
111
+
112
+ # https://github.com/gitpython-developers/GitPython/issues/427
113
+ self.repo = git.Repo(repo_paths.pop(), odbt=git.GitDB)
114
+ self.root = utils.safe_abs_path(self.repo.working_tree_dir)
115
+
116
+ if patch_ignore_file:
117
+ self.patch_ignore_file = Path(patch_ignore_file)
118
+
119
+ def commit(self, fnames=None, context=None, message=None, patch_edits=False, coder=None):
120
+ """
121
+ Commit the specified files or all dirty files if none are specified.
122
+
123
+ Args:
124
+ fnames (list, optional): List of filenames to commit. Defaults to None (commit all
125
+ dirty files).
126
+ context (str, optional): Context for generating commit message. Defaults to None.
127
+ message (str, optional): Explicit commit message. Defaults to None (generate message).
128
+ patch_edits (bool, optional): Whether the changes were made by Patch. Defaults to False.
129
+ This affects attribution logic.
130
+ coder (Coder, optional): The Coder instance, used for config and model info.
131
+ Defaults to None.
132
+
133
+ Returns:
134
+ tuple(str, str) or None: The commit hash and commit message if successful,
135
+ else None.
136
+
137
+ Attribution Logic:
138
+ ------------------
139
+ This method handles Git commit attribution based on configuration flags and whether
140
+ Patch generated the changes (`patch_edits`).
141
+
142
+ Key Concepts:
143
+ - Author: The person who originally wrote the code changes.
144
+ - Committer: The person who last applied the commit to the repository.
145
+ - patch_edits=True: Changes were generated by Patch (LLM).
146
+ - patch_edits=False: Commit is user-driven (e.g., /commit manually staged changes).
147
+ - Explicit Setting: A flag (--attribute-...) is set to True or False
148
+ via command line or config file.
149
+ - Implicit Default: A flag is not explicitly set, defaulting to None in args, which is
150
+ interpreted as True unless overridden by other logic.
151
+
152
+ Flags:
153
+ - --attribute-author: Modify Author name to "User Name (patch)".
154
+ - --attribute-committer: Modify Committer name to "User Name (patch)".
155
+ - --attribute-co-authored-by: Add
156
+ "Co-authored-by: patch (<model>) <patch@getpatch.dev>" trailer to commit message.
157
+
158
+ Behavior Summary:
159
+
160
+ 1. When patch_edits = True (AI Changes):
161
+ - If --attribute-co-authored-by=True:
162
+ - Co-authored-by trailer IS ADDED.
163
+ - Author/Committer names are NOT modified by default (co-authored-by takes precedence).
164
+ - EXCEPTION: If --attribute-author/--attribute-committer is EXPLICITLY True, the
165
+ respective name IS modified (explicit overrides precedence).
166
+ - If --attribute-co-authored-by=False:
167
+ - Co-authored-by trailer is NOT added.
168
+ - Author/Committer names ARE modified by default (implicit True).
169
+ - EXCEPTION: If --attribute-author/--attribute-committer is EXPLICITLY False,
170
+ the respective name is NOT modified.
171
+
172
+ 2. When patch_edits = False (User Changes):
173
+ - --attribute-co-authored-by is IGNORED (trailer never added).
174
+ - Author name is NEVER modified (--attribute-author ignored).
175
+ - Committer name IS modified by default (implicit True, as Patch runs `git commit`).
176
+ - EXCEPTION: If --attribute-committer is EXPLICITLY False, the name is NOT modified.
177
+
178
+ Resulting Scenarios:
179
+ - Standard AI edit (defaults): Co-authored-by=False -> Author=You(patch),
180
+ Committer=You(patch)
181
+ - AI edit with Co-authored-by (default): Co-authored-by=True -> Author=You,
182
+ Committer=You, Trailer added
183
+ - AI edit with Co-authored-by + Explicit Author: Co-authored-by=True,
184
+ --attribute-author -> Author=You(patch), Committer=You, Trailer added
185
+ - User commit (defaults): patch_edits=False -> Author=You, Committer=You(patch)
186
+ - User commit with explicit no-committer: patch_edits=False,
187
+ --no-attribute-committer -> Author=You, Committer=You
188
+ """
189
+ if not fnames and not self.repo.is_dirty():
190
+ return
191
+
192
+ diffs = self.get_diffs(fnames)
193
+ if not diffs:
194
+ return
195
+
196
+ if message:
197
+ commit_message = message
198
+ else:
199
+ user_language = None
200
+ if coder:
201
+ user_language = coder.commit_language
202
+ if not user_language:
203
+ user_language = coder.get_user_language()
204
+ commit_message = self.get_commit_message(diffs, context, user_language)
205
+
206
+ # Retrieve attribute settings, prioritizing coder.args if available
207
+ if coder and hasattr(coder, "args"):
208
+ attribute_author = coder.args.attribute_author
209
+ attribute_committer = coder.args.attribute_committer
210
+ attribute_commit_message_author = coder.args.attribute_commit_message_author
211
+ attribute_commit_message_committer = coder.args.attribute_commit_message_committer
212
+ attribute_co_authored_by = coder.args.attribute_co_authored_by
213
+ else:
214
+ # Fallback to self attributes (initialized from config/defaults)
215
+ attribute_author = self.attribute_author
216
+ attribute_committer = self.attribute_committer
217
+ attribute_commit_message_author = self.attribute_commit_message_author
218
+ attribute_commit_message_committer = self.attribute_commit_message_committer
219
+ attribute_co_authored_by = self.attribute_co_authored_by
220
+
221
+ # Determine explicit settings (None means use default behavior)
222
+ author_explicit = attribute_author is not None
223
+ committer_explicit = attribute_committer is not None
224
+
225
+ # Determine effective settings (apply default True if not explicit)
226
+ effective_author = True if attribute_author is None else attribute_author
227
+ effective_committer = True if attribute_committer is None else attribute_committer
228
+
229
+ # Determine commit message prefixing
230
+ prefix_commit_message = patch_edits and (
231
+ attribute_commit_message_author or attribute_commit_message_committer
232
+ )
233
+
234
+ # Determine Co-authored-by trailer
235
+ commit_message_trailer = ""
236
+ if patch_edits and attribute_co_authored_by:
237
+ model_name = "unknown-model"
238
+ if coder and hasattr(coder, "main_model") and coder.main_model.name:
239
+ model_name = coder.main_model.name
240
+ commit_message_trailer = (
241
+ f"\n\nCo-authored-by: patch ({model_name}) <patch@getpatch.dev>"
242
+ )
243
+
244
+ # Determine if author/committer names should be modified
245
+ # Author modification applies only to patch edits.
246
+ # It's used if effective_author is True AND
247
+ # (co-authored-by is False OR author was explicitly set).
248
+ use_attribute_author = (
249
+ patch_edits and effective_author and (not attribute_co_authored_by or author_explicit)
250
+ )
251
+
252
+ # Committer modification applies regardless of patch_edits (based on tests).
253
+ # It's used if effective_committer is True AND
254
+ # (it's not an patch edit with co-authored-by OR committer was explicitly set).
255
+ use_attribute_committer = effective_committer and (
256
+ not (patch_edits and attribute_co_authored_by) or committer_explicit
257
+ )
258
+
259
+ if not commit_message:
260
+ commit_message = "(no commit message provided)"
261
+
262
+ if prefix_commit_message:
263
+ commit_message = "patch: " + commit_message
264
+
265
+ full_commit_message = commit_message + commit_message_trailer
266
+
267
+ cmd = ["-m", full_commit_message]
268
+ if not self.git_commit_verify:
269
+ cmd.append("--no-verify")
270
+ if fnames:
271
+ fnames = [str(self.abs_root_path(fn)) for fn in fnames]
272
+ for fname in fnames:
273
+ try:
274
+ self.repo.git.add(fname)
275
+ except ANY_GIT_ERROR as err:
276
+ self.io.tool_error(f"Unable to add {fname}: {err}")
277
+ cmd += ["--"] + fnames
278
+ else:
279
+ cmd += ["-a"]
280
+
281
+ original_user_name = self.repo.git.config("--get", "user.name")
282
+ original_committer_name_env = os.environ.get("GIT_COMMITTER_NAME")
283
+ original_author_name_env = os.environ.get("GIT_AUTHOR_NAME")
284
+ committer_name = f"{original_user_name} (patch)"
285
+
286
+ try:
287
+ # Use context managers to handle environment variables
288
+ with contextlib.ExitStack() as stack:
289
+ if use_attribute_committer:
290
+ stack.enter_context(
291
+ set_git_env(
292
+ "GIT_COMMITTER_NAME", committer_name, original_committer_name_env
293
+ )
294
+ )
295
+ if use_attribute_author:
296
+ stack.enter_context(
297
+ set_git_env("GIT_AUTHOR_NAME", committer_name, original_author_name_env)
298
+ )
299
+
300
+ # Perform the commit
301
+ self.repo.git.commit(cmd)
302
+ commit_hash = self.get_head_commit_sha(short=True)
303
+ self.io.tool_output(f"Commit {commit_hash} {commit_message}", bold=True)
304
+ return commit_hash, commit_message
305
+
306
+ except ANY_GIT_ERROR as err:
307
+ self.io.tool_error(f"Unable to commit: {err}")
308
+ # No return here, implicitly returns None
309
+
310
+ def get_rel_repo_dir(self):
311
+ try:
312
+ return os.path.relpath(self.repo.git_dir, os.getcwd())
313
+ except (ValueError, OSError):
314
+ return self.repo.git_dir
315
+
316
+ def get_commit_message(self, diffs, context, user_language=None):
317
+ diffs = "# Diffs:\n" + diffs
318
+
319
+ content = ""
320
+ if context:
321
+ content += context + "\n"
322
+ content += diffs
323
+
324
+ system_content = self.commit_prompt or prompts.commit_system
325
+
326
+ language_instruction = ""
327
+ if user_language:
328
+ language_instruction = f"\n- Is written in {user_language}."
329
+ system_content = system_content.format(language_instruction=language_instruction)
330
+
331
+ commit_message = None
332
+ for model in self.models:
333
+ spinner_text = f"Generating commit message with {model.name}"
334
+ with WaitingSpinner(spinner_text):
335
+ if model.system_prompt_prefix:
336
+ current_system_content = model.system_prompt_prefix + "\n" + system_content
337
+ else:
338
+ current_system_content = system_content
339
+
340
+ messages = [
341
+ dict(role="system", content=current_system_content),
342
+ dict(role="user", content=content),
343
+ ]
344
+
345
+ num_tokens = model.token_count(messages)
346
+ max_tokens = model.info.get("max_input_tokens") or 0
347
+
348
+ if max_tokens and num_tokens > max_tokens:
349
+ continue
350
+
351
+ commit_message = model.simple_send_with_retries(messages)
352
+ if commit_message:
353
+ break # Found a model that could generate the message
354
+
355
+ if not commit_message:
356
+ self.io.tool_error("Failed to generate commit message!")
357
+ return
358
+
359
+ commit_message = commit_message.strip()
360
+ if commit_message and commit_message[0] == '"' and commit_message[-1] == '"':
361
+ commit_message = commit_message[1:-1].strip()
362
+
363
+ return commit_message
364
+
365
+ def get_diffs(self, fnames=None):
366
+ # We always want diffs of index and working dir
367
+
368
+ current_branch_has_commits = False
369
+ try:
370
+ active_branch = self.repo.active_branch
371
+ try:
372
+ commits = self.repo.iter_commits(active_branch)
373
+ current_branch_has_commits = any(commits)
374
+ except ANY_GIT_ERROR:
375
+ pass
376
+ except (TypeError,) + ANY_GIT_ERROR:
377
+ pass
378
+
379
+ if not fnames:
380
+ fnames = []
381
+
382
+ diffs = ""
383
+ for fname in fnames:
384
+ if not self.path_in_repo(fname):
385
+ diffs += f"Added {fname}\n"
386
+
387
+ try:
388
+ if current_branch_has_commits:
389
+ args = ["HEAD", "--"] + list(fnames)
390
+ diffs += self.repo.git.diff(*args, stdout_as_string=False).decode(
391
+ self.io.encoding, "replace"
392
+ )
393
+ return diffs
394
+
395
+ wd_args = ["--"] + list(fnames)
396
+ index_args = ["--cached"] + wd_args
397
+
398
+ diffs += self.repo.git.diff(*index_args, stdout_as_string=False).decode(
399
+ self.io.encoding, "replace"
400
+ )
401
+ diffs += self.repo.git.diff(*wd_args, stdout_as_string=False).decode(
402
+ self.io.encoding, "replace"
403
+ )
404
+
405
+ return diffs
406
+ except ANY_GIT_ERROR as err:
407
+ self.io.tool_error(f"Unable to diff: {err}")
408
+
409
+ def diff_commits(self, pretty, from_commit, to_commit):
410
+ args = []
411
+ if pretty:
412
+ args += ["--color"]
413
+ else:
414
+ args += ["--color=never"]
415
+
416
+ args += [from_commit, to_commit]
417
+ diffs = self.repo.git.diff(*args, stdout_as_string=False).decode(
418
+ self.io.encoding, "replace"
419
+ )
420
+
421
+ return diffs
422
+
423
+ def get_tracked_files(self):
424
+ if not self.repo:
425
+ return []
426
+
427
+ try:
428
+ commit = self.repo.head.commit
429
+ except ValueError:
430
+ commit = None
431
+ except ANY_GIT_ERROR as err:
432
+ self.git_repo_error = err
433
+ self.io.tool_error(f"Unable to list files in git repo: {err}")
434
+ self.io.tool_output("Is your git repo corrupted?")
435
+ return []
436
+
437
+ files = set()
438
+ if commit:
439
+ if commit in self.tree_files:
440
+ files = self.tree_files[commit]
441
+ else:
442
+ try:
443
+ iterator = commit.tree.traverse()
444
+ blob = None # Initialize blob
445
+ while True:
446
+ try:
447
+ blob = next(iterator)
448
+ if blob.type == "blob": # blob is a file
449
+ files.add(blob.path)
450
+ except IndexError:
451
+ # Handle potential index error during tree traversal
452
+ # without relying on potentially unassigned 'blob'
453
+ self.io.tool_warning(
454
+ "GitRepo: Index error encountered while reading git tree object."
455
+ " Skipping."
456
+ )
457
+ continue
458
+ except StopIteration:
459
+ break
460
+ except ANY_GIT_ERROR as err:
461
+ self.git_repo_error = err
462
+ self.io.tool_error(f"Unable to list files in git repo: {err}")
463
+ self.io.tool_output("Is your git repo corrupted?")
464
+ return []
465
+ files = set(self.normalize_path(path) for path in files)
466
+ self.tree_files[commit] = set(files)
467
+
468
+ # Add staged files
469
+ index = self.repo.index
470
+ try:
471
+ staged_files = [path for path, _ in index.entries.keys()]
472
+ files.update(self.normalize_path(path) for path in staged_files)
473
+ except ANY_GIT_ERROR as err:
474
+ self.io.tool_error(f"Unable to read staged files: {err}")
475
+
476
+ res = [fname for fname in files if not self.ignored_file(fname)]
477
+
478
+ return res
479
+
480
+ def normalize_path(self, path):
481
+ orig_path = path
482
+ res = self.normalized_path.get(orig_path)
483
+ if res:
484
+ return res
485
+
486
+ path = str(Path(PurePosixPath((Path(self.root) / path).relative_to(self.root))))
487
+ self.normalized_path[orig_path] = path
488
+ return path
489
+
490
+ def refresh_patch_ignore(self):
491
+ if not self.patch_ignore_file:
492
+ return
493
+
494
+ current_time = time.time()
495
+ if current_time - self.patch_ignore_last_check < 1:
496
+ return
497
+
498
+ self.patch_ignore_last_check = current_time
499
+
500
+ if not self.patch_ignore_file.is_file():
501
+ return
502
+
503
+ mtime = self.patch_ignore_file.stat().st_mtime
504
+ if mtime != self.patch_ignore_ts:
505
+ self.patch_ignore_ts = mtime
506
+ self.ignore_file_cache = {}
507
+ lines = self.patch_ignore_file.read_text().splitlines()
508
+ self.patch_ignore_spec = pathspec.PathSpec.from_lines(
509
+ pathspec.patterns.GitWildMatchPattern,
510
+ lines,
511
+ )
512
+
513
+ def git_ignored_file(self, path):
514
+ if not self.repo:
515
+ return
516
+ try:
517
+ if self.repo.ignored(path):
518
+ return True
519
+ except ANY_GIT_ERROR:
520
+ return False
521
+
522
+ def ignored_file(self, fname):
523
+ self.refresh_patch_ignore()
524
+
525
+ if fname in self.ignore_file_cache:
526
+ return self.ignore_file_cache[fname]
527
+
528
+ result = self.ignored_file_raw(fname)
529
+ self.ignore_file_cache[fname] = result
530
+ return result
531
+
532
+ def ignored_file_raw(self, fname):
533
+ if self.subtree_only:
534
+ try:
535
+ fname_path = Path(self.normalize_path(fname))
536
+ cwd_path = Path.cwd().resolve().relative_to(Path(self.root).resolve())
537
+ except ValueError:
538
+ # Issue #1524
539
+ # ValueError: 'C:\\dev\\squid-certbot' is not in the subpath of
540
+ # 'C:\\dev\\squid-certbot'
541
+ # Clearly, fname is not under cwd... so ignore it
542
+ return True
543
+
544
+ if cwd_path not in fname_path.parents and fname_path != cwd_path:
545
+ return True
546
+
547
+ if not self.patch_ignore_file or not self.patch_ignore_file.is_file():
548
+ return False
549
+
550
+ try:
551
+ fname = self.normalize_path(fname)
552
+ except ValueError:
553
+ return True
554
+
555
+ return self.patch_ignore_spec.match_file(fname)
556
+
557
+ def path_in_repo(self, path):
558
+ if not self.repo:
559
+ return
560
+ if not path:
561
+ return
562
+
563
+ tracked_files = set(self.get_tracked_files())
564
+ normalized = self.normalize_path(path)
565
+ return normalized in tracked_files
566
+
567
+ def abs_root_path(self, path):
568
+ res = Path(self.root) / path
569
+ return utils.safe_abs_path(res)
570
+
571
+ def get_dirty_files(self):
572
+ """
573
+ Returns a list of all files which are dirty (not committed), either staged or in the working
574
+ directory.
575
+ """
576
+ dirty_files = set()
577
+
578
+ # Get staged files
579
+ staged_files = self.repo.git.diff("--name-only", "--cached").splitlines()
580
+ dirty_files.update(staged_files)
581
+
582
+ # Get unstaged files
583
+ unstaged_files = self.repo.git.diff("--name-only").splitlines()
584
+ dirty_files.update(unstaged_files)
585
+
586
+ return list(dirty_files)
587
+
588
+ def is_dirty(self, path=None):
589
+ if path and not self.path_in_repo(path):
590
+ return True
591
+
592
+ return self.repo.is_dirty(path=path)
593
+
594
+ def get_head_commit(self):
595
+ try:
596
+ return self.repo.head.commit
597
+ except (ValueError,) + ANY_GIT_ERROR:
598
+ return None
599
+
600
+ def get_head_commit_sha(self, short=False):
601
+ commit = self.get_head_commit()
602
+ if not commit:
603
+ return
604
+ if short:
605
+ return commit.hexsha[:7]
606
+ return commit.hexsha
607
+
608
+ def get_head_commit_message(self, default=None):
609
+ commit = self.get_head_commit()
610
+ if not commit:
611
+ return default
612
+ return commit.message