trops 0.2.34__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.
- trops/__init__.py +10 -0
- trops/capcmd.py +376 -0
- trops/env.py +364 -0
- trops/exec.py +192 -0
- trops/file.py +82 -0
- trops/init.py +109 -0
- trops/log.py +196 -0
- trops/release.py +1 -0
- trops/repo.py +124 -0
- trops/tablog.py +297 -0
- trops/tldr.py +293 -0
- trops/trops.py +550 -0
- trops/utils.py +98 -0
- trops/view.py +320 -0
- trops-0.2.34.dist-info/LICENSE +21 -0
- trops-0.2.34.dist-info/METADATA +189 -0
- trops-0.2.34.dist-info/RECORD +19 -0
- trops-0.2.34.dist-info/WHEEL +4 -0
- trops-0.2.34.dist-info/entry_points.txt +3 -0
trops/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
# Minimal import-time checks only. Heavyweight runtime checks should live in command handlers.
|
|
4
|
+
required_python_major = 3
|
|
5
|
+
required_python_minor = 8
|
|
6
|
+
if sys.version_info < (required_python_major, required_python_minor):
|
|
7
|
+
raise SystemExit(
|
|
8
|
+
f"ERROR: This program requires Python {required_python_major}.{required_python_minor} or newer. "
|
|
9
|
+
f"Current version: {'.'.join(map(str, sys.version_info[:3]))}"
|
|
10
|
+
)
|
trops/capcmd.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Tuple
|
|
8
|
+
from configparser import ConfigParser
|
|
9
|
+
|
|
10
|
+
from .trops import TropsBase, TropsError
|
|
11
|
+
import re
|
|
12
|
+
from .utils import absolute_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TropsCapCmd(TropsBase):
|
|
16
|
+
"""Trops Capture Command class"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, args, other_args):
|
|
19
|
+
# Enforce TROPS_DIR for capture-cmd only (per project requirement)
|
|
20
|
+
if 'TROPS_DIR' not in os.environ:
|
|
21
|
+
raise TropsError('ERROR: The TROPS_DIR environment variable has not been set.')
|
|
22
|
+
super().__init__(args, other_args)
|
|
23
|
+
|
|
24
|
+
# If TROPS_ENV is specified but missing in config, error out early with a clear message
|
|
25
|
+
conf_path = os.path.join(self.trops_dir, 'trops.cfg')
|
|
26
|
+
if self.trops_env and os.path.isfile(conf_path):
|
|
27
|
+
config = ConfigParser()
|
|
28
|
+
config.read(conf_path)
|
|
29
|
+
if not config.has_section(self.trops_env):
|
|
30
|
+
raise TropsError(f"ERROR: TROPS_ENV '{self.trops_env}' does not exist in your configuration at {conf_path}.")
|
|
31
|
+
|
|
32
|
+
# Ensure attributes exist even when no config section is present
|
|
33
|
+
# This avoids AttributeError later and provides sane defaults
|
|
34
|
+
if not hasattr(self, 'ignore_cmds'):
|
|
35
|
+
self.ignore_cmds = {'ttags'}
|
|
36
|
+
if not hasattr(self, 'disable_header'):
|
|
37
|
+
self.disable_header = False
|
|
38
|
+
|
|
39
|
+
# Start setting the header with stable positions: trops|env|sid|tags
|
|
40
|
+
header_env = getattr(self, 'trops_env', '') or ''
|
|
41
|
+
header_sid = getattr(self, 'trops_sid', '') or ''
|
|
42
|
+
header_tags = getattr(self, 'trops_tags', '') or ''
|
|
43
|
+
self.trops_header = ['trops', header_env, header_sid, header_tags]
|
|
44
|
+
# Defer FL logs until after command logging to preserve real-world order
|
|
45
|
+
self._defer_file_logs = False
|
|
46
|
+
self._deferred_file_logs = []
|
|
47
|
+
|
|
48
|
+
def _flush_deferred_file_logs(self) -> None:
|
|
49
|
+
"""Flush and clear any deferred file logs."""
|
|
50
|
+
if getattr(self, '_deferred_file_logs', None):
|
|
51
|
+
for fl_message in self._deferred_file_logs:
|
|
52
|
+
self.logger.info(fl_message)
|
|
53
|
+
self._defer_file_logs = False
|
|
54
|
+
self._deferred_file_logs = []
|
|
55
|
+
|
|
56
|
+
def capture_cmd(self) -> None:
|
|
57
|
+
"""Capture and log the executed command"""
|
|
58
|
+
|
|
59
|
+
return_code = self.args.return_code
|
|
60
|
+
now_hm = datetime.now().strftime("%H-%M")
|
|
61
|
+
|
|
62
|
+
# Enable deferring of file logs that may be produced by pre-processing
|
|
63
|
+
self._defer_file_logs = True
|
|
64
|
+
self._deferred_file_logs = []
|
|
65
|
+
|
|
66
|
+
if not self.other_args:
|
|
67
|
+
# No command to log; flush any deferred logs then exit
|
|
68
|
+
self._flush_deferred_file_logs()
|
|
69
|
+
self.print_header()
|
|
70
|
+
sys.exit(0)
|
|
71
|
+
|
|
72
|
+
executed_cmd = self.other_args
|
|
73
|
+
time_and_cmd = f"{now_hm} {' '.join(executed_cmd)}"
|
|
74
|
+
|
|
75
|
+
# Fast-path: skip early if command is in ignore list (performance)
|
|
76
|
+
sanitized_for_ignore = self._sanitize_for_sudo(executed_cmd)
|
|
77
|
+
if self.ignore_cmds and sanitized_for_ignore and sanitized_for_ignore[0] in self.ignore_cmds:
|
|
78
|
+
# Ignored command; flush deferred logs to preserve previous behavior
|
|
79
|
+
self._flush_deferred_file_logs()
|
|
80
|
+
self.print_header()
|
|
81
|
+
sys.exit(0)
|
|
82
|
+
|
|
83
|
+
# Ensure tmp directory exists
|
|
84
|
+
tmp_dir = Path(self.trops_dir) / 'tmp'
|
|
85
|
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
last_cmd_path = tmp_dir / 'last_cmd'
|
|
87
|
+
|
|
88
|
+
# Side-effect operations that should happen even if the command is repeated
|
|
89
|
+
# 1) Track files edited by common editors
|
|
90
|
+
self._track_editor_files(executed_cmd)
|
|
91
|
+
# 2) Track files written via tee
|
|
92
|
+
wrote_with_tee = self._add_tee_output_file(executed_cmd)
|
|
93
|
+
# 3) Try pushing if remote is configured and we actually added/updated files
|
|
94
|
+
if wrote_with_tee:
|
|
95
|
+
self._push_if_remote_set()
|
|
96
|
+
|
|
97
|
+
# Skip if repeated within the same minute (after performing file updates)
|
|
98
|
+
if self._is_repeat_command(str(last_cmd_path), time_and_cmd):
|
|
99
|
+
if not self.disable_header:
|
|
100
|
+
# Repeated command; flush deferred logs to preserve previous behavior
|
|
101
|
+
self._flush_deferred_file_logs()
|
|
102
|
+
self.print_header()
|
|
103
|
+
sys.exit(0)
|
|
104
|
+
|
|
105
|
+
# Save last command signature
|
|
106
|
+
self._save_last_command(str(last_cmd_path), time_and_cmd)
|
|
107
|
+
|
|
108
|
+
# (kept for clarity; normally unreachable due to early fast-path above)
|
|
109
|
+
sanitized_for_ignore = self._sanitize_for_sudo(executed_cmd)
|
|
110
|
+
if self.ignore_cmds and sanitized_for_ignore and sanitized_for_ignore[0] in self.ignore_cmds:
|
|
111
|
+
# Ignored command; flush deferred logs to preserve previous behavior
|
|
112
|
+
self._flush_deferred_file_logs()
|
|
113
|
+
self.print_header()
|
|
114
|
+
sys.exit(0)
|
|
115
|
+
|
|
116
|
+
# Log command message
|
|
117
|
+
message = self._compose_capture_message(executed_cmd, return_code)
|
|
118
|
+
if return_code == 0:
|
|
119
|
+
self.logger.info(message)
|
|
120
|
+
else:
|
|
121
|
+
self.logger.warning(message)
|
|
122
|
+
|
|
123
|
+
# Flush any deferred file logs after the command has been logged
|
|
124
|
+
self._flush_deferred_file_logs()
|
|
125
|
+
|
|
126
|
+
if not self.disable_header:
|
|
127
|
+
self.print_header()
|
|
128
|
+
|
|
129
|
+
def _compose_capture_message(self, executed_cmd: List[str], return_code: int) -> str:
|
|
130
|
+
parts: List[str] = [
|
|
131
|
+
f"CM {' '.join(executed_cmd)} #> PWD={os.getenv('PWD')}",
|
|
132
|
+
f"EXIT={return_code}",
|
|
133
|
+
]
|
|
134
|
+
if self.trops_sid:
|
|
135
|
+
parts.append(f"TROPS_SID={self.trops_sid}")
|
|
136
|
+
if self.trops_env:
|
|
137
|
+
parts.append(f"TROPS_ENV={self.trops_env}")
|
|
138
|
+
if self.trops_tags:
|
|
139
|
+
parts.append(f"TROPS_TAGS={self.trops_tags}")
|
|
140
|
+
return ', '.join(parts)
|
|
141
|
+
|
|
142
|
+
def _is_repeat_command(self, last_cmd_path, time_and_cmd):
|
|
143
|
+
"""Check if the current command is a repeat of the last command"""
|
|
144
|
+
if os.path.isfile(last_cmd_path):
|
|
145
|
+
with open(last_cmd_path, 'r') as f:
|
|
146
|
+
return time_and_cmd == f.read()
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
def _save_last_command(self, last_cmd_path, time_and_cmd):
|
|
150
|
+
"""Save the current command as the last executed command"""
|
|
151
|
+
with open(last_cmd_path, 'w') as f:
|
|
152
|
+
f.write(time_and_cmd)
|
|
153
|
+
|
|
154
|
+
def print_header(self):
|
|
155
|
+
# Print -= trops|env|sid|tags =-
|
|
156
|
+
print(f'\n-= {"|".join(self.trops_header)} =-')
|
|
157
|
+
|
|
158
|
+
def _yum_log(self, executed_cmd: List[str]) -> None:
|
|
159
|
+
|
|
160
|
+
# Check if sudo is used
|
|
161
|
+
executed_cmd = executed_cmd[1:] if executed_cmd[0] == 'sudo' else executed_cmd
|
|
162
|
+
|
|
163
|
+
if executed_cmd[0] in ['yum', 'dnf'] and any(x in executed_cmd for x in ['install', 'update', 'remove']):
|
|
164
|
+
cmd = ['rpm', '-qa']
|
|
165
|
+
result = subprocess.run(cmd, capture_output=True, check=True)
|
|
166
|
+
pkg_list = result.stdout.decode('utf-8').splitlines()
|
|
167
|
+
pkg_list.sort()
|
|
168
|
+
|
|
169
|
+
pkg_list_file = os.path.join(self.trops_dir, f'log/rpm_pkg_list.{self.hostname}')
|
|
170
|
+
with open(pkg_list_file, 'w') as f:
|
|
171
|
+
f.write('\n'.join(pkg_list))
|
|
172
|
+
|
|
173
|
+
self.add_and_commit_file(pkg_list_file)
|
|
174
|
+
|
|
175
|
+
def _apt_log(self, executed_cmd: List[str]) -> None:
|
|
176
|
+
if 'apt' in executed_cmd and any(x in executed_cmd for x in ['upgrade', 'install', 'update', 'remove', 'autoremove']):
|
|
177
|
+
self._update_pkg_list(' '.join(executed_cmd))
|
|
178
|
+
# TODO: Add log trops git show hex
|
|
179
|
+
|
|
180
|
+
def _update_pkg_list(self, args: str) -> None:
|
|
181
|
+
|
|
182
|
+
# Update the pkg_List
|
|
183
|
+
cmd = ['apt', 'list', '--installed']
|
|
184
|
+
result = subprocess.run(cmd, capture_output=True)
|
|
185
|
+
pkg_list = result.stdout.decode('utf-8').splitlines()
|
|
186
|
+
pkg_list.sort()
|
|
187
|
+
|
|
188
|
+
pkg_list_file = self.trops_dir + \
|
|
189
|
+
f'/log/apt_pkg_list.{ self.hostname }'
|
|
190
|
+
with open(pkg_list_file, 'w') as f:
|
|
191
|
+
f.write('\n'.join(pkg_list))
|
|
192
|
+
|
|
193
|
+
self.add_and_commit_file(pkg_list_file)
|
|
194
|
+
|
|
195
|
+
def _add_file_in_git_repo(self, executed_cmd: List[str], start_index: int, first_line_comment: str = None) -> None:
|
|
196
|
+
for file_arg in executed_cmd[start_index:]:
|
|
197
|
+
file_path = absolute_path(file_arg)
|
|
198
|
+
if not os.path.isfile(file_path):
|
|
199
|
+
continue
|
|
200
|
+
# Optionally prepend a comment line describing the source command (for tee outputs)
|
|
201
|
+
if first_line_comment:
|
|
202
|
+
try:
|
|
203
|
+
with open(file_path, 'r+', encoding='utf-8', errors='ignore') as f:
|
|
204
|
+
content = f.read()
|
|
205
|
+
if not content.startswith(first_line_comment):
|
|
206
|
+
f.seek(0)
|
|
207
|
+
f.write(first_line_comment + "\n" + content)
|
|
208
|
+
except Exception:
|
|
209
|
+
try:
|
|
210
|
+
with open(file_path, 'w', encoding='utf-8', errors='ignore') as f:
|
|
211
|
+
f.write(first_line_comment + "\n")
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
# Ignore if path is already tracked in another repo
|
|
215
|
+
if file_is_in_a_git_repo(file_path):
|
|
216
|
+
self.logger.info(
|
|
217
|
+
f"FL {file_path} is under a git repository #> PWD=*, EXIT=*, TROPS_SID={self.trops_sid}, TROPS_ENV={self.trops_env}")
|
|
218
|
+
sys.exit(0)
|
|
219
|
+
git_msg, log_note = self._generate_git_msg_and_log_note(file_path)
|
|
220
|
+
result = self._add_and_commit_file(file_path, git_msg)
|
|
221
|
+
if result.returncode == 0:
|
|
222
|
+
msg = result.stdout.decode('utf-8').splitlines()[0]
|
|
223
|
+
print(msg)
|
|
224
|
+
self._add_file_log(file_path, log_note)
|
|
225
|
+
# Push immediately after a successful commit if remote is set
|
|
226
|
+
self._push_if_remote_set()
|
|
227
|
+
else:
|
|
228
|
+
print('No update')
|
|
229
|
+
|
|
230
|
+
def _add_file_log(self, file_path: str, log_note: str) -> None:
|
|
231
|
+
"""Add an FL log entry"""
|
|
232
|
+
rel_path = os.path.relpath(os.path.realpath(absolute_path(file_path)), start=os.path.realpath(self.work_tree))
|
|
233
|
+
cmd = self.git_cmd + ['log', '--oneline', '-1', rel_path]
|
|
234
|
+
output = subprocess.check_output(
|
|
235
|
+
cmd).decode("utf-8").split()
|
|
236
|
+
if rel_path in output:
|
|
237
|
+
mode = oct(os.stat(file_path).st_mode)[-4:]
|
|
238
|
+
owner = Path(file_path).owner()
|
|
239
|
+
group = Path(file_path).group()
|
|
240
|
+
message = f"FL trops show { output[0] }:{ rel_path } #> { log_note }, O={ owner },G={ group },M={ mode }"
|
|
241
|
+
if self.trops_sid:
|
|
242
|
+
message += f" TROPS_SID={ self.trops_sid }"
|
|
243
|
+
message += f" TROPS_ENV={ self.trops_env }"
|
|
244
|
+
if self.trops_tags:
|
|
245
|
+
message += f" TROPS_TAGS={self.trops_tags}"
|
|
246
|
+
# Defer logging if requested so that command log comes first
|
|
247
|
+
if getattr(self, '_defer_file_logs', False):
|
|
248
|
+
self._deferred_file_logs.append(message)
|
|
249
|
+
else:
|
|
250
|
+
self.logger.info(message)
|
|
251
|
+
|
|
252
|
+
def _add_and_commit_file(self, file_path: str, git_msg: str) -> subprocess.CompletedProcess:
|
|
253
|
+
"""Add a file in the git repo and commit if changed"""
|
|
254
|
+
rel_path = os.path.relpath(os.path.realpath(absolute_path(file_path)), start=os.path.realpath(self.work_tree))
|
|
255
|
+
subprocess.run(self.git_cmd + ['add', rel_path], capture_output=True)
|
|
256
|
+
return subprocess.run(self.git_cmd + ['commit', '-m', git_msg, rel_path], capture_output=True)
|
|
257
|
+
|
|
258
|
+
def _generate_git_msg_and_log_note(self, file_path: str) -> Tuple[str, str]:
|
|
259
|
+
"""Generate the git commit message and log note"""
|
|
260
|
+
rel_path = os.path.relpath(os.path.realpath(absolute_path(file_path)), start=os.path.realpath(self.work_tree))
|
|
261
|
+
result = subprocess.run(self.git_cmd + ['ls-files', rel_path], capture_output=True)
|
|
262
|
+
is_tracked = bool(result.stdout.decode('utf-8'))
|
|
263
|
+
git_msg = f"{'Update' if is_tracked else 'Add'} {rel_path}"
|
|
264
|
+
log_note = 'UPDATE' if is_tracked else 'ADD'
|
|
265
|
+
if self.trops_tags:
|
|
266
|
+
git_msg = f"{git_msg} ({self.trops_tags})"
|
|
267
|
+
return git_msg, log_note
|
|
268
|
+
|
|
269
|
+
def _track_editor_files(self, executed_cmd: List[str]) -> None:
|
|
270
|
+
"""Detect common editors and add the edited file(s) to the repo if present."""
|
|
271
|
+
|
|
272
|
+
# Remove sudo from executed_cmd (basic case)
|
|
273
|
+
executed_cmd = self._sanitize_for_sudo(executed_cmd)
|
|
274
|
+
|
|
275
|
+
# Check if editor is launched
|
|
276
|
+
editors = ['vim', 'vi', 'nvim', 'emacs', 'nano']
|
|
277
|
+
if executed_cmd[0] in editors:
|
|
278
|
+
# Add the edited file in trops git
|
|
279
|
+
self._add_file_in_git_repo(executed_cmd, 1)
|
|
280
|
+
|
|
281
|
+
def _add_tee_output_file(self, executed_cmd: List[str]) -> bool:
|
|
282
|
+
"""Detect tee after one or more pipes and add the target file(s).
|
|
283
|
+
|
|
284
|
+
Supported forms:
|
|
285
|
+
- cmd | tee path/to/file
|
|
286
|
+
- cmd |tee path/to/file
|
|
287
|
+
- cmd1 | cmd2 | ... | tee path/to/file
|
|
288
|
+
"""
|
|
289
|
+
# First normalize tokens so that every '|' is its own token
|
|
290
|
+
normalized: List[str] = []
|
|
291
|
+
for tok in executed_cmd:
|
|
292
|
+
if '|' in tok and tok != '|':
|
|
293
|
+
parts = re.split(r'(\|)', tok)
|
|
294
|
+
normalized.extend([p for p in parts if p])
|
|
295
|
+
else:
|
|
296
|
+
normalized.append(tok)
|
|
297
|
+
|
|
298
|
+
# Scan for the last occurrence of '|' followed by 'tee'
|
|
299
|
+
last_pipe_index = -1
|
|
300
|
+
for i in range(len(normalized) - 1):
|
|
301
|
+
if normalized[i] == '|' and normalized[i + 1] == 'tee':
|
|
302
|
+
last_pipe_index = i # index of '|' just before tee
|
|
303
|
+
|
|
304
|
+
if last_pipe_index != -1:
|
|
305
|
+
# Determine the left-hand command (before the last pipe that precedes tee)
|
|
306
|
+
left_cmd_tokens = normalized[:last_pipe_index]
|
|
307
|
+
left_cmd = ' '.join(left_cmd_tokens).strip()
|
|
308
|
+
comment = f"# {left_cmd}" if left_cmd else None
|
|
309
|
+
# Start collecting path arguments after 'tee'
|
|
310
|
+
tee_index = last_pipe_index + 1 # 'tee'
|
|
311
|
+
self._add_file_in_git_repo(normalized, tee_index + 1, first_line_comment=comment)
|
|
312
|
+
return True
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
def _sanitize_for_sudo(self, executed_cmd: List[str]) -> List[str]:
|
|
316
|
+
"""Remove leading sudo if present. TODO: handle sudo options."""
|
|
317
|
+
if executed_cmd and executed_cmd[0] == 'sudo':
|
|
318
|
+
return executed_cmd[1:]
|
|
319
|
+
return executed_cmd
|
|
320
|
+
|
|
321
|
+
def _push_if_remote_set(self) -> None:
|
|
322
|
+
"""Push current branch if a git remote is configured.
|
|
323
|
+
|
|
324
|
+
This is a no-op when:
|
|
325
|
+
- no git_remote is configured
|
|
326
|
+
- git_dir is missing or not a valid directory
|
|
327
|
+
- git config file does not exist
|
|
328
|
+
"""
|
|
329
|
+
if not getattr(self, 'git_remote', False):
|
|
330
|
+
return
|
|
331
|
+
if not hasattr(self, 'git_dir') or not isinstance(self.git_dir, str):
|
|
332
|
+
return
|
|
333
|
+
if not os.path.isdir(self.git_dir):
|
|
334
|
+
return
|
|
335
|
+
git_config_path = os.path.join(self.git_dir, 'config')
|
|
336
|
+
if not os.path.isfile(git_config_path):
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
# Determine current branch
|
|
340
|
+
result = subprocess.run(self.git_cmd + ['branch', '--show-current'], capture_output=True)
|
|
341
|
+
current_branch = result.stdout.decode('utf-8').strip() if result.returncode == 0 else ''
|
|
342
|
+
if not current_branch:
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
git_conf = ConfigParser()
|
|
346
|
+
git_conf.read(git_config_path)
|
|
347
|
+
|
|
348
|
+
# Ensure origin exists
|
|
349
|
+
if not git_conf.has_option('remote "origin"', 'url'):
|
|
350
|
+
subprocess.call(self.git_cmd + ['remote', 'add', 'origin', self.git_remote])
|
|
351
|
+
|
|
352
|
+
# Set upstream if missing, else regular push
|
|
353
|
+
if not git_conf.has_option(f'branch "{current_branch}"', 'remote'):
|
|
354
|
+
cmd = self.git_cmd + ['push', '--set-upstream', 'origin', current_branch]
|
|
355
|
+
else:
|
|
356
|
+
cmd = self.git_cmd + ['push']
|
|
357
|
+
subprocess.call(cmd)
|
|
358
|
+
|
|
359
|
+
def capture_cmd(args, other_args):
|
|
360
|
+
|
|
361
|
+
tc = TropsCapCmd(args, other_args)
|
|
362
|
+
tc.capture_cmd()
|
|
363
|
+
|
|
364
|
+
def add_capture_cmd_subparsers(subparsers):
|
|
365
|
+
|
|
366
|
+
parser_capture_cmd = subparsers.add_parser(
|
|
367
|
+
'capture-cmd', help='Capture command line strings', add_help=False)
|
|
368
|
+
parser_capture_cmd.add_argument(
|
|
369
|
+
'return_code', type=int, help='return code')
|
|
370
|
+
parser_capture_cmd.set_defaults(handler=capture_cmd)
|
|
371
|
+
|
|
372
|
+
def file_is_in_a_git_repo(file_path: str) -> bool:
|
|
373
|
+
parent_dir = os.path.dirname(file_path) or '.'
|
|
374
|
+
# Use git -C to avoid changing global working directory
|
|
375
|
+
result = subprocess.run(['git', '-C', parent_dir, 'rev-parse', '--is-inside-work-tree'], capture_output=True)
|
|
376
|
+
return result.returncode == 0
|