batchfetch 1.3.7__tar.gz → 1.3.8__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.
- {batchfetch-1.3.7 → batchfetch-1.3.8}/PKG-INFO +20 -20
- {batchfetch-1.3.7 → batchfetch-1.3.8}/README.md +19 -19
- {batchfetch-1.3.7 → batchfetch-1.3.8}/batchfetch/batchfetch_base.py +21 -2
- {batchfetch-1.3.7 → batchfetch-1.3.8}/batchfetch/batchfetch_cli.py +14 -6
- {batchfetch-1.3.7 → batchfetch-1.3.8}/batchfetch/batchfetch_git.py +111 -26
- {batchfetch-1.3.7 → batchfetch-1.3.8}/batchfetch.egg-info/PKG-INFO +20 -20
- {batchfetch-1.3.7 → batchfetch-1.3.8}/setup.py +1 -1
- {batchfetch-1.3.7 → batchfetch-1.3.8}/LICENSE +0 -0
- {batchfetch-1.3.7 → batchfetch-1.3.8}/batchfetch/__init__.py +0 -0
- {batchfetch-1.3.7 → batchfetch-1.3.8}/batchfetch/helpers.py +0 -0
- {batchfetch-1.3.7 → batchfetch-1.3.8}/batchfetch.egg-info/SOURCES.txt +0 -0
- {batchfetch-1.3.7 → batchfetch-1.3.8}/batchfetch.egg-info/dependency_links.txt +0 -0
- {batchfetch-1.3.7 → batchfetch-1.3.8}/batchfetch.egg-info/entry_points.txt +0 -0
- {batchfetch-1.3.7 → batchfetch-1.3.8}/batchfetch.egg-info/requires.txt +0 -0
- {batchfetch-1.3.7 → batchfetch-1.3.8}/batchfetch.egg-info/top_level.txt +0 -0
- {batchfetch-1.3.7 → batchfetch-1.3.8}/setup.cfg +0 -0
- {batchfetch-1.3.7 → batchfetch-1.3.8}/tests/test_helpers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: batchfetch
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.8
|
|
4
4
|
Summary: Efficiently clone and pull multiple Git repositories.
|
|
5
5
|
Home-page: https://github.com/jamescherti/batchfetch
|
|
6
6
|
Author: James Cherti
|
|
@@ -80,7 +80,7 @@ tasks:
|
|
|
80
80
|
delete: true
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
Execute the `batchfetch` command from the same directory as `batchfetch.
|
|
83
|
+
Execute the `batchfetch` command from the same directory as `batchfetch.yaml` to make it clone or update the local copies of the repositories above.
|
|
84
84
|
|
|
85
85
|
## Command-line options
|
|
86
86
|
|
|
@@ -94,7 +94,7 @@ Efficiently clone/pull multiple Git repositories in parallel.
|
|
|
94
94
|
positional arguments:
|
|
95
95
|
target This is a target path that batchfetch is supposed to
|
|
96
96
|
handle. When no target is specified, execute the tasks
|
|
97
|
-
of all target paths defined in the batchfetch.
|
|
97
|
+
of all target paths defined in the batchfetch.yaml list
|
|
98
98
|
of tasks.
|
|
99
99
|
|
|
100
100
|
options:
|
|
@@ -173,20 +173,6 @@ tasks:
|
|
|
173
173
|
revision: b9c6d9b6134b4981760893254f804a371ffbc899
|
|
174
174
|
```
|
|
175
175
|
|
|
176
|
-
### How to configure additional Git remotes?
|
|
177
|
-
|
|
178
|
-
You can define additional remotes for a Git repository using the `remote` option. Pass a list of key-value pairs where the key is the remote name and the value is the corresponding URL. Batchfetch will automatically ensure these remotes are present and point to the correct URLs.
|
|
179
|
-
|
|
180
|
-
```yaml
|
|
181
|
-
---
|
|
182
|
-
tasks:
|
|
183
|
-
- git: https://github.com/jamescherti/easysession.el
|
|
184
|
-
path: emacs/easysession
|
|
185
|
-
remote:
|
|
186
|
-
upstream: https://github.com/name/repo
|
|
187
|
-
upstream2: https://github.com/name/repo2
|
|
188
|
-
```
|
|
189
|
-
|
|
190
176
|
### How to customize Git clone, merge, and update strategies?
|
|
191
177
|
|
|
192
178
|
Batchfetch allows you to define advanced options for interacting with Git repositories. These parameters can be declared globally under the `options` block to apply to all tasks, or individually within a specific task to override the global defaults.
|
|
@@ -219,6 +205,20 @@ tasks:
|
|
|
219
205
|
|
|
220
206
|
```
|
|
221
207
|
|
|
208
|
+
### How to configure additional Git remotes?
|
|
209
|
+
|
|
210
|
+
You can define additional remotes for a Git repository using the `remote` option. Pass a list of key-value pairs where the key is the remote name and the value is the corresponding URL. Batchfetch will automatically ensure these remotes are present and point to the correct URLs.
|
|
211
|
+
|
|
212
|
+
```yaml
|
|
213
|
+
---
|
|
214
|
+
tasks:
|
|
215
|
+
- git: https://github.com/jamescherti/easysession.el
|
|
216
|
+
path: emacs/easysession
|
|
217
|
+
remote:
|
|
218
|
+
upstream: https://github.com/name/repo
|
|
219
|
+
upstream2: https://github.com/name/repo2
|
|
220
|
+
```
|
|
221
|
+
|
|
222
222
|
### How to execute a command before and after a task?
|
|
223
223
|
|
|
224
224
|
To execute a command both before and after a specific task, you can define the `exec_before` and `exec_after` directives within the task configuration. These directives specify commands to be executed at the respective stages of the task lifecycle.
|
|
@@ -236,9 +236,9 @@ tasks:
|
|
|
236
236
|
|
|
237
237
|
### How to make batchfetch handle only one path?
|
|
238
238
|
|
|
239
|
-
To configure `batchfetch` to handle a specific path, you can define your tasks in a `batchfetch.
|
|
239
|
+
To configure `batchfetch` to handle a specific path, you can define your tasks in a `batchfetch.yaml` file and pass the desired path as an argument to the `batchfetch` command.
|
|
240
240
|
|
|
241
|
-
#### Example `batchfetch.
|
|
241
|
+
#### Example `batchfetch.yaml` file:
|
|
242
242
|
|
|
243
243
|
In the following example, the `easysession` task clones two Git repositories:
|
|
244
244
|
|
|
@@ -258,7 +258,7 @@ To make `batchfetch` clone only `easysession`, pass its path as an argument:
|
|
|
258
258
|
batchfetch easysession
|
|
259
259
|
```
|
|
260
260
|
|
|
261
|
-
This will execute only the task corresponding to the `easysession` path, skipping all others in the `batchfetch.
|
|
261
|
+
This will execute only the task corresponding to the `easysession` path, skipping all others in the `batchfetch.yaml` file.
|
|
262
262
|
|
|
263
263
|
### How can I configure batchfetch to load a file other than batchfetch.yaml?
|
|
264
264
|
|
|
@@ -46,7 +46,7 @@ tasks:
|
|
|
46
46
|
delete: true
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
Execute the `batchfetch` command from the same directory as `batchfetch.
|
|
49
|
+
Execute the `batchfetch` command from the same directory as `batchfetch.yaml` to make it clone or update the local copies of the repositories above.
|
|
50
50
|
|
|
51
51
|
## Command-line options
|
|
52
52
|
|
|
@@ -60,7 +60,7 @@ Efficiently clone/pull multiple Git repositories in parallel.
|
|
|
60
60
|
positional arguments:
|
|
61
61
|
target This is a target path that batchfetch is supposed to
|
|
62
62
|
handle. When no target is specified, execute the tasks
|
|
63
|
-
of all target paths defined in the batchfetch.
|
|
63
|
+
of all target paths defined in the batchfetch.yaml list
|
|
64
64
|
of tasks.
|
|
65
65
|
|
|
66
66
|
options:
|
|
@@ -139,20 +139,6 @@ tasks:
|
|
|
139
139
|
revision: b9c6d9b6134b4981760893254f804a371ffbc899
|
|
140
140
|
```
|
|
141
141
|
|
|
142
|
-
### How to configure additional Git remotes?
|
|
143
|
-
|
|
144
|
-
You can define additional remotes for a Git repository using the `remote` option. Pass a list of key-value pairs where the key is the remote name and the value is the corresponding URL. Batchfetch will automatically ensure these remotes are present and point to the correct URLs.
|
|
145
|
-
|
|
146
|
-
```yaml
|
|
147
|
-
---
|
|
148
|
-
tasks:
|
|
149
|
-
- git: https://github.com/jamescherti/easysession.el
|
|
150
|
-
path: emacs/easysession
|
|
151
|
-
remote:
|
|
152
|
-
upstream: https://github.com/name/repo
|
|
153
|
-
upstream2: https://github.com/name/repo2
|
|
154
|
-
```
|
|
155
|
-
|
|
156
142
|
### How to customize Git clone, merge, and update strategies?
|
|
157
143
|
|
|
158
144
|
Batchfetch allows you to define advanced options for interacting with Git repositories. These parameters can be declared globally under the `options` block to apply to all tasks, or individually within a specific task to override the global defaults.
|
|
@@ -185,6 +171,20 @@ tasks:
|
|
|
185
171
|
|
|
186
172
|
```
|
|
187
173
|
|
|
174
|
+
### How to configure additional Git remotes?
|
|
175
|
+
|
|
176
|
+
You can define additional remotes for a Git repository using the `remote` option. Pass a list of key-value pairs where the key is the remote name and the value is the corresponding URL. Batchfetch will automatically ensure these remotes are present and point to the correct URLs.
|
|
177
|
+
|
|
178
|
+
```yaml
|
|
179
|
+
---
|
|
180
|
+
tasks:
|
|
181
|
+
- git: https://github.com/jamescherti/easysession.el
|
|
182
|
+
path: emacs/easysession
|
|
183
|
+
remote:
|
|
184
|
+
upstream: https://github.com/name/repo
|
|
185
|
+
upstream2: https://github.com/name/repo2
|
|
186
|
+
```
|
|
187
|
+
|
|
188
188
|
### How to execute a command before and after a task?
|
|
189
189
|
|
|
190
190
|
To execute a command both before and after a specific task, you can define the `exec_before` and `exec_after` directives within the task configuration. These directives specify commands to be executed at the respective stages of the task lifecycle.
|
|
@@ -202,9 +202,9 @@ tasks:
|
|
|
202
202
|
|
|
203
203
|
### How to make batchfetch handle only one path?
|
|
204
204
|
|
|
205
|
-
To configure `batchfetch` to handle a specific path, you can define your tasks in a `batchfetch.
|
|
205
|
+
To configure `batchfetch` to handle a specific path, you can define your tasks in a `batchfetch.yaml` file and pass the desired path as an argument to the `batchfetch` command.
|
|
206
206
|
|
|
207
|
-
#### Example `batchfetch.
|
|
207
|
+
#### Example `batchfetch.yaml` file:
|
|
208
208
|
|
|
209
209
|
In the following example, the `easysession` task clones two Git repositories:
|
|
210
210
|
|
|
@@ -224,7 +224,7 @@ To make `batchfetch` clone only `easysession`, pass its path as an argument:
|
|
|
224
224
|
batchfetch easysession
|
|
225
225
|
```
|
|
226
226
|
|
|
227
|
-
This will execute only the task corresponding to the `easysession` path, skipping all others in the `batchfetch.
|
|
227
|
+
This will execute only the task corresponding to the `easysession` path, skipping all others in the `batchfetch.yaml` file.
|
|
228
228
|
|
|
229
229
|
### How can I configure batchfetch to load a file other than batchfetch.yaml?
|
|
230
230
|
|
|
@@ -31,7 +31,7 @@ from .helpers import run_indent_str
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
class BatchFetchError(Exception):
|
|
34
|
-
"""Exception raised by
|
|
34
|
+
"""Exception raised by batchfetch functions."""
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
class DataAlreadyInitialized(Exception):
|
|
@@ -39,7 +39,10 @@ class DataAlreadyInitialized(Exception):
|
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
class TaskBase:
|
|
42
|
+
"""Base class representing a general task to be executed."""
|
|
43
|
+
|
|
42
44
|
def __init__(self, data: dict[str, Any], options: dict[str, Any]) -> None:
|
|
45
|
+
"""Initialize the task with specific data and options."""
|
|
43
46
|
self.global_options_schema: dict[Any, Any] = {}
|
|
44
47
|
self.global_options_values: dict[str, Any] = options
|
|
45
48
|
|
|
@@ -54,6 +57,7 @@ class TaskBase:
|
|
|
54
57
|
self._values_initialized = False
|
|
55
58
|
|
|
56
59
|
def _initialize_data(self) -> None:
|
|
60
|
+
"""Initialize the options and data values for the task."""
|
|
57
61
|
if self._values_initialized:
|
|
58
62
|
raise DataAlreadyInitialized
|
|
59
63
|
|
|
@@ -79,9 +83,11 @@ class TaskBase:
|
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
def validate_schema(self) -> None:
|
|
86
|
+
"""Validate the schema of the task data and options."""
|
|
82
87
|
self._initialize_data()
|
|
83
88
|
|
|
84
89
|
def __getitem__(self, key: str) -> Any:
|
|
90
|
+
"""Retrieve an item from the task values or options."""
|
|
85
91
|
self._initialize_data()
|
|
86
92
|
|
|
87
93
|
if key in self.values:
|
|
@@ -90,13 +96,14 @@ class TaskBase:
|
|
|
90
96
|
if key in self.options:
|
|
91
97
|
return self.options[key]
|
|
92
98
|
|
|
93
|
-
raise KeyError(f"The item '{key}' was not found in '{self.values}")
|
|
99
|
+
raise KeyError(f"The item '{key}' was not found in '{self.values}'")
|
|
94
100
|
|
|
95
101
|
|
|
96
102
|
class TaskBatchFetch(TaskBase):
|
|
97
103
|
"""Plugin downloader base class."""
|
|
98
104
|
|
|
99
105
|
def __init__(self, data: dict[str, Any], options: dict[str, Any]) -> None:
|
|
106
|
+
"""Initialize the batchfetch task plugin."""
|
|
100
107
|
new_options: dict[str, Any] = {"exec_before": [],
|
|
101
108
|
"exec_after": [],
|
|
102
109
|
"ignore_untracked": []}
|
|
@@ -132,6 +139,7 @@ class TaskBatchFetch(TaskBase):
|
|
|
132
139
|
}
|
|
133
140
|
|
|
134
141
|
def _initialize_data(self) -> None:
|
|
142
|
+
"""Safely initialize data for the batchfetch task."""
|
|
135
143
|
try:
|
|
136
144
|
super()._initialize_data()
|
|
137
145
|
except DataAlreadyInitialized:
|
|
@@ -141,6 +149,7 @@ class TaskBatchFetch(TaskBase):
|
|
|
141
149
|
self._values_initialized = True
|
|
142
150
|
|
|
143
151
|
def _local_task_exec(self, *args: Any, **kwargs: Any) -> None:
|
|
152
|
+
"""Execute a local task command and capture its output."""
|
|
144
153
|
stdout = run_indent_str(env=self.env, spaces=self.indent,
|
|
145
154
|
*args, **kwargs)
|
|
146
155
|
if not stdout.endswith("\n"):
|
|
@@ -148,6 +157,7 @@ class TaskBatchFetch(TaskBase):
|
|
|
148
157
|
self.add_output(stdout)
|
|
149
158
|
|
|
150
159
|
def _exec_before(self, cwd: os.PathLike = Path(".")) -> None:
|
|
160
|
+
"""Execute pre-task commands defined in options and task items."""
|
|
151
161
|
self._initialize_data()
|
|
152
162
|
if self["delete"]:
|
|
153
163
|
return
|
|
@@ -165,6 +175,7 @@ class TaskBatchFetch(TaskBase):
|
|
|
165
175
|
self._local_task_exec(cmd, cwd=str(cwd))
|
|
166
176
|
|
|
167
177
|
def _exec_after(self, cwd: os.PathLike = Path(".")) -> None:
|
|
178
|
+
"""Execute post-task commands if the task was changed successfully."""
|
|
168
179
|
self._initialize_data()
|
|
169
180
|
if self["delete"] or self.is_error() or not self.is_changed():
|
|
170
181
|
return
|
|
@@ -182,32 +193,39 @@ class TaskBatchFetch(TaskBase):
|
|
|
182
193
|
self._local_task_exec(cmd, cwd=str(cwd))
|
|
183
194
|
|
|
184
195
|
def is_changed(self) -> bool:
|
|
196
|
+
"""Return True if the task execution resulted in changes."""
|
|
185
197
|
self._initialize_data()
|
|
186
198
|
return bool(self.values["result"]["changed"])
|
|
187
199
|
|
|
188
200
|
def set_changed(self, changed: bool) -> None:
|
|
201
|
+
"""Set the changed status of the task."""
|
|
189
202
|
self._initialize_data()
|
|
190
203
|
self.values["result"]["changed"] = changed
|
|
191
204
|
|
|
192
205
|
def get_changed(self) -> bool:
|
|
206
|
+
"""Return the changed status of the task without initializing data."""
|
|
193
207
|
try:
|
|
194
208
|
return bool(self.values["result"]["changed"])
|
|
195
209
|
except KeyError:
|
|
196
210
|
return False
|
|
197
211
|
|
|
198
212
|
def is_error(self) -> bool:
|
|
213
|
+
"""Return True if the task encountered an error."""
|
|
199
214
|
self._initialize_data()
|
|
200
215
|
return bool(self.values["result"]["error"])
|
|
201
216
|
|
|
202
217
|
def set_error(self, error: bool) -> None:
|
|
218
|
+
"""Set the error status of the task."""
|
|
203
219
|
self._initialize_data()
|
|
204
220
|
self.values["result"]["error"] = error
|
|
205
221
|
|
|
206
222
|
def set_output(self, output: str) -> None:
|
|
223
|
+
"""Set the output string of the task."""
|
|
207
224
|
self._initialize_data()
|
|
208
225
|
self.values["result"]["output"] = output
|
|
209
226
|
|
|
210
227
|
def add_output(self, output: str) -> None:
|
|
228
|
+
"""Append a string to the output of the task."""
|
|
211
229
|
self._initialize_data()
|
|
212
230
|
self.values["result"]["output"] += output
|
|
213
231
|
|
|
@@ -217,5 +235,6 @@ class TaskBatchFetch(TaskBase):
|
|
|
217
235
|
return str(self.values["result"]["output"])
|
|
218
236
|
|
|
219
237
|
def update(self) -> dict[str, Any]:
|
|
238
|
+
"""Update the task data and return the current values."""
|
|
220
239
|
self._initialize_data()
|
|
221
240
|
return self.values
|
|
@@ -47,11 +47,12 @@ class BatchFetchCli:
|
|
|
47
47
|
# pylint: disable=too-many-positional-arguments
|
|
48
48
|
def __init__(self, max_workers: int, verbose: bool, check_untracked: bool,
|
|
49
49
|
targets: list[str], cwd: os.PathLike) -> None:
|
|
50
|
+
"""Initialize the batchfetch command-line interface."""
|
|
50
51
|
os.chdir(cwd)
|
|
51
52
|
self._cwd = cwd
|
|
52
53
|
self._logger = logging.getLogger(self.__class__.__name__)
|
|
53
54
|
self.targets = {Path(item).absolute() for item in targets}
|
|
54
|
-
self.cfg: dict = {}
|
|
55
|
+
self.cfg: dict[str, Any] = {}
|
|
55
56
|
self.check_untracked = check_untracked
|
|
56
57
|
self.tracked_paths: dict[Path, set[str]] = {}
|
|
57
58
|
self.ignore_untracked: set[Path] = set()
|
|
@@ -66,6 +67,7 @@ class BatchFetchCli:
|
|
|
66
67
|
self._plugin_add("git", BatchFetchGit)
|
|
67
68
|
|
|
68
69
|
def _plugin_add(self, keyword: str, batchfetch_class: Any) -> None:
|
|
70
|
+
"""Register a plugin class and its schema."""
|
|
69
71
|
batchfetch_instance = batchfetch_class(data={},
|
|
70
72
|
options={})
|
|
71
73
|
batchfetch_instance.validate_schema()
|
|
@@ -81,6 +83,7 @@ class BatchFetchCli:
|
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
def _plugin_get(self, raw_data: dict[str, Any]) -> str:
|
|
86
|
+
"""Retrieve the correct plugin keyword based on raw data."""
|
|
84
87
|
keyword_found = None
|
|
85
88
|
for keyword in self.batchfetch_classes:
|
|
86
89
|
if keyword in raw_data:
|
|
@@ -99,10 +102,12 @@ class BatchFetchCli:
|
|
|
99
102
|
return keyword_found
|
|
100
103
|
|
|
101
104
|
def load(self, path: Path) -> None:
|
|
105
|
+
"""Load and parse the batchfetch YAML configuration file."""
|
|
102
106
|
# pylint: disable=too-many-try-statements
|
|
103
107
|
try:
|
|
104
108
|
with open(path, "r", encoding="utf-8") as fhandler:
|
|
105
|
-
yaml_dict =
|
|
109
|
+
yaml_dict: Any = \
|
|
110
|
+
yaml.load(fhandler, Loader=yaml.FullLoader)
|
|
106
111
|
if not isinstance(yaml_dict, dict):
|
|
107
112
|
print(f"Error: Invalid format: {yaml_dict}.",
|
|
108
113
|
file=sys.stderr)
|
|
@@ -142,6 +147,7 @@ class BatchFetchCli:
|
|
|
142
147
|
raise BatchFetchError(str(err)) from err
|
|
143
148
|
|
|
144
149
|
def _loads(self, data: dict[str, Any]) -> None:
|
|
150
|
+
"""Validate and load the configuration data dictionary."""
|
|
145
151
|
schema = Schema(self.cfg_schema)
|
|
146
152
|
try:
|
|
147
153
|
schema.validate(data)
|
|
@@ -160,6 +166,7 @@ class BatchFetchCli:
|
|
|
160
166
|
self._loads_tasks(data)
|
|
161
167
|
|
|
162
168
|
def _loads_tasks(self, data: dict[str, Any]) -> None:
|
|
169
|
+
"""Load and validate tasks from the configuration data."""
|
|
163
170
|
if "tasks" not in data:
|
|
164
171
|
return
|
|
165
172
|
|
|
@@ -191,6 +198,7 @@ class BatchFetchCli:
|
|
|
191
198
|
# pylint: disable=too-many-branches
|
|
192
199
|
# pylint: disable=too-many-statements
|
|
193
200
|
def run_tasks(self) -> bool:
|
|
201
|
+
"""Execute all configured tasks in parallel using a thread pool."""
|
|
194
202
|
failed = []
|
|
195
203
|
error = False
|
|
196
204
|
threads = []
|
|
@@ -279,7 +287,7 @@ class BatchFetchCli:
|
|
|
279
287
|
|
|
280
288
|
return False
|
|
281
289
|
else:
|
|
282
|
-
if self.check_untracked:
|
|
290
|
+
if self.check_untracked and not self.targets:
|
|
283
291
|
self._find_untracked_paths()
|
|
284
292
|
|
|
285
293
|
if num_success == 0:
|
|
@@ -358,7 +366,7 @@ def parse_args() -> argparse.Namespace:
|
|
|
358
366
|
"targets", metavar="target", type=str, nargs="*",
|
|
359
367
|
help=("This is a target path that batchfetch is supposed to handle. "
|
|
360
368
|
"When no target is specified, execute the tasks of all target "
|
|
361
|
-
"paths defined in the batchfetch.
|
|
369
|
+
"paths defined in the batchfetch.yaml list of tasks."),
|
|
362
370
|
)
|
|
363
371
|
|
|
364
372
|
parser.add_argument(
|
|
@@ -421,7 +429,7 @@ def parse_args() -> argparse.Namespace:
|
|
|
421
429
|
|
|
422
430
|
|
|
423
431
|
def command_line_interface() -> None:
|
|
424
|
-
"""
|
|
432
|
+
"""Entry point for the command line interface."""
|
|
425
433
|
# pylint: disable=too-many-try-statements
|
|
426
434
|
try:
|
|
427
435
|
errno = 0
|
|
@@ -459,7 +467,7 @@ def command_line_interface() -> None:
|
|
|
459
467
|
except KeyboardInterrupt:
|
|
460
468
|
print("Interrupted.", file=sys.stderr)
|
|
461
469
|
errno = 1
|
|
462
|
-
except (yaml.
|
|
470
|
+
except (yaml.error.MarkedYAMLError, BatchFetchError) as err:
|
|
463
471
|
print(f"Error: {err}.", file=sys.stderr)
|
|
464
472
|
errno = 1
|
|
465
473
|
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
+
import logging
|
|
23
24
|
import os
|
|
24
25
|
import shutil
|
|
25
26
|
import subprocess
|
|
@@ -45,9 +46,10 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
45
46
|
"""Clone or update a Git repository."""
|
|
46
47
|
|
|
47
48
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
49
|
+
"""Initialize the Git batchfetch task."""
|
|
48
50
|
self._git_fetch_origin_done = False
|
|
49
51
|
|
|
50
|
-
self.branch_commit_ref = None
|
|
52
|
+
self.branch_commit_ref: str | None = None
|
|
51
53
|
self.is_branch = False
|
|
52
54
|
|
|
53
55
|
super().__init__(*args, **kwargs)
|
|
@@ -90,10 +92,11 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
90
92
|
})
|
|
91
93
|
|
|
92
94
|
self.git_local_dir = Path(self["path"])
|
|
93
|
-
self.current_branch = None
|
|
94
|
-
self.current_commit_ref = None
|
|
95
|
+
self.current_branch: str | None = None
|
|
96
|
+
self.current_commit_ref: str | None = None
|
|
95
97
|
|
|
96
98
|
def _initialize_data(self) -> None:
|
|
99
|
+
"""Initialize and parse the repository path data."""
|
|
97
100
|
super()._initialize_data()
|
|
98
101
|
|
|
99
102
|
self.values[self.main_key] = \
|
|
@@ -149,8 +152,15 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
149
152
|
self._exec_before(cwd=self.git_local_dir)
|
|
150
153
|
|
|
151
154
|
if not self["revision"]:
|
|
152
|
-
|
|
153
|
-
|
|
155
|
+
# Attempt to read the default branch from local origin/HEAD
|
|
156
|
+
sym_ref = self._run_get_firstline(
|
|
157
|
+
["git", "symbolic-ref", "--short",
|
|
158
|
+
"refs/remotes/origin/HEAD"]
|
|
159
|
+
).strip()
|
|
160
|
+
if sym_ref.startswith("origin/"):
|
|
161
|
+
self.values["revision"] = \
|
|
162
|
+
sym_ref.split("origin/", 1)[1]
|
|
163
|
+
|
|
154
164
|
if not self["revision"]:
|
|
155
165
|
raise BatchFetchError(
|
|
156
166
|
"Unable to determine the default origin branch"
|
|
@@ -187,6 +197,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
187
197
|
return self.values
|
|
188
198
|
|
|
189
199
|
def _run_get_firstline(self, *args: Any, **kwargs: Any) -> str:
|
|
200
|
+
"""Run a command and return the first line of standard output."""
|
|
190
201
|
stdout, _ = self._run(*args, **kwargs)
|
|
191
202
|
try:
|
|
192
203
|
return stdout[0]
|
|
@@ -194,8 +205,8 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
194
205
|
return ""
|
|
195
206
|
|
|
196
207
|
def _run(self, cmd: Union[list[str], str],
|
|
197
|
-
cwd: Union[None, os.PathLike] = None,
|
|
198
|
-
env: Union[None, dict] = None,
|
|
208
|
+
cwd: Union[None, os.PathLike, str] = None,
|
|
209
|
+
env: Union[None, dict[str, str]] = None,
|
|
199
210
|
**kwargs: Any) -> tuple[list[str], list[str]]:
|
|
200
211
|
"""Execute a command and return stdout and stderr.
|
|
201
212
|
|
|
@@ -214,6 +225,33 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
214
225
|
env = self.env
|
|
215
226
|
return run_simple(cmd=cmd, env=env, cwd=cwd, **kwargs)
|
|
216
227
|
|
|
228
|
+
def _get_upstream_branch(self) -> str:
|
|
229
|
+
"""Return the upstream tracking branch for the current branch."""
|
|
230
|
+
if not self.current_branch:
|
|
231
|
+
raise BatchFetchError(
|
|
232
|
+
"Not currently on any branch. Cannot determine upstream."
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
stdout, _ = self._run(
|
|
237
|
+
["git", "rev-parse", "--abbrev-ref",
|
|
238
|
+
f"{self.current_branch}@{{upstream}}"]
|
|
239
|
+
)
|
|
240
|
+
# TODO: Don't repeat strip 2 times
|
|
241
|
+
if stdout and stdout[0].strip():
|
|
242
|
+
return stdout[0].strip()
|
|
243
|
+
except subprocess.CalledProcessError as err:
|
|
244
|
+
raise BatchFetchError(
|
|
245
|
+
"No upstream tracking branch found "
|
|
246
|
+
f"for '{self.current_branch}'. "
|
|
247
|
+
"Please configure an upstream branch before updating."
|
|
248
|
+
) from err
|
|
249
|
+
|
|
250
|
+
raise BatchFetchError(
|
|
251
|
+
"Failed to parse upstream tracking "
|
|
252
|
+
f"branch for '{self.current_branch}'."
|
|
253
|
+
)
|
|
254
|
+
|
|
217
255
|
def _git_ref(self, cwd: Union[None, Path] = None) -> str:
|
|
218
256
|
"""Get the commit revision of HEAD.
|
|
219
257
|
|
|
@@ -228,6 +266,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
228
266
|
return output
|
|
229
267
|
|
|
230
268
|
def _update_current_branch_name(self) -> None:
|
|
269
|
+
"""Update instance variables with the current branch and commit."""
|
|
231
270
|
try:
|
|
232
271
|
# This returns the branch name
|
|
233
272
|
stdout, _ = self._run(["git", "symbolic-ref", "--short", "HEAD"])
|
|
@@ -250,6 +289,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
250
289
|
pass
|
|
251
290
|
|
|
252
291
|
def _repo_delete(self) -> None:
|
|
292
|
+
"""Delete the local Git repository directory safely."""
|
|
253
293
|
if not self.git_local_dir.exists():
|
|
254
294
|
self.add_output(self.indent_spaces + "[INFO] Already deleted\n")
|
|
255
295
|
elif not self.git_local_dir.joinpath(".git").is_dir():
|
|
@@ -266,6 +306,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
266
306
|
self.set_changed(True)
|
|
267
307
|
|
|
268
308
|
def _repo_clone(self) -> None:
|
|
309
|
+
"""Execute git clone for the task repository."""
|
|
269
310
|
git_clone_args = self["git_clone_args"]
|
|
270
311
|
# git_clone_args += ["--recurse-submodules"]
|
|
271
312
|
|
|
@@ -275,14 +316,16 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
275
316
|
self.set_changed(True)
|
|
276
317
|
|
|
277
318
|
def _repo_fetch(self) -> bool:
|
|
319
|
+
"""Fetch the remote repository and determine if an update is needed."""
|
|
278
320
|
# Merge
|
|
279
321
|
do_git_fetch = self["git_pull"]
|
|
280
|
-
do_git_fetch = False
|
|
281
322
|
|
|
282
323
|
try:
|
|
283
324
|
# Check if the revision such as
|
|
284
325
|
_, _ = self._run(["git", "cat-file", "-e", self["revision"]])
|
|
285
|
-
except subprocess.CalledProcessError:
|
|
326
|
+
except subprocess.CalledProcessError as err:
|
|
327
|
+
logging.debug("Ignoring subprocess error checking cat-file: %s",
|
|
328
|
+
err)
|
|
286
329
|
do_git_fetch = True
|
|
287
330
|
self.add_output(
|
|
288
331
|
self.indent_spaces
|
|
@@ -303,8 +346,11 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
303
346
|
+ f"{self['revision']} is a branch, not a tag"
|
|
304
347
|
+ "\n")
|
|
305
348
|
do_git_fetch = True
|
|
306
|
-
except subprocess.CalledProcessError:
|
|
307
|
-
|
|
349
|
+
except subprocess.CalledProcessError as err:
|
|
350
|
+
logging.debug(
|
|
351
|
+
"Ignoring subprocess error checking show-ref: %s",
|
|
352
|
+
err,
|
|
353
|
+
)
|
|
308
354
|
|
|
309
355
|
if not do_git_fetch:
|
|
310
356
|
self.add_output(self.indent_spaces + "[INFO] git fetch ignored\n")
|
|
@@ -323,6 +369,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
323
369
|
return False
|
|
324
370
|
|
|
325
371
|
def _git_apply_update_strategy(self) -> bool:
|
|
372
|
+
"""Merge, rebase, or reset local branches according to the strategy."""
|
|
326
373
|
git_updated = False
|
|
327
374
|
|
|
328
375
|
# Merge, Rebase, or Reset
|
|
@@ -343,34 +390,44 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
343
390
|
"prevent data loss."
|
|
344
391
|
)
|
|
345
392
|
|
|
393
|
+
# Retrieve upstream branch or raise exception if not found
|
|
394
|
+
upstream_branch = self._get_upstream_branch()
|
|
395
|
+
|
|
346
396
|
self.add_output(
|
|
347
397
|
self.indent_spaces
|
|
348
398
|
+ f"[INFO] Applying update strategy: {strategy} from "
|
|
349
|
-
f"
|
|
399
|
+
f"{upstream_branch}\n"
|
|
350
400
|
)
|
|
351
401
|
|
|
352
402
|
if strategy == "rebase":
|
|
353
|
-
self._run(["git", "rebase"] +
|
|
354
|
-
[f"origin/{self.current_branch}"])
|
|
403
|
+
self._run(["git", "rebase"] + [upstream_branch])
|
|
355
404
|
elif strategy == "reset":
|
|
356
|
-
self._run(["git", "reset", "--hard",
|
|
357
|
-
f"origin/{self.current_branch}"])
|
|
405
|
+
self._run(["git", "reset", "--hard", upstream_branch])
|
|
358
406
|
else:
|
|
359
407
|
self._run(["git", "merge"] + self["git_merge_args"] +
|
|
360
|
-
[
|
|
408
|
+
[upstream_branch])
|
|
361
409
|
|
|
362
410
|
git_ref_after_update = self._git_ref(cwd=self.git_local_dir)
|
|
363
411
|
if commit_ref_head != git_ref_after_update:
|
|
364
412
|
git_updated = True
|
|
365
413
|
self.set_changed(True)
|
|
366
|
-
self._run([
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
414
|
+
stdout, _ = self._run([
|
|
415
|
+
"git", "log",
|
|
416
|
+
"--pretty=format:%h %ad %s [%cn]",
|
|
417
|
+
"--decorate", "--date=short",
|
|
418
|
+
f"{commit_ref_head}..{git_ref_after_update}"
|
|
419
|
+
])
|
|
420
|
+
if stdout:
|
|
421
|
+
self.add_output(self.indent_spaces +
|
|
422
|
+
"[INFO] Updated commits:\n")
|
|
423
|
+
for line in stdout:
|
|
424
|
+
self.add_output(self.indent_spaces +
|
|
425
|
+
" " + line + "\n")
|
|
370
426
|
|
|
371
427
|
return git_updated
|
|
372
428
|
|
|
373
429
|
def _git_get_remote_url(self, remote_name: str = "origin") -> str:
|
|
430
|
+
"""Retrieve the configured url for the given remote name."""
|
|
374
431
|
origin_url = ""
|
|
375
432
|
try:
|
|
376
433
|
stdout, _ = self._run(["git", "config",
|
|
@@ -384,6 +441,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
384
441
|
|
|
385
442
|
def _git_set_remote_url(self, url: str,
|
|
386
443
|
remote_name: str = "origin") -> str:
|
|
444
|
+
"""Reconfigure or add a Git remote with a new target url."""
|
|
387
445
|
origin_url = ""
|
|
388
446
|
try:
|
|
389
447
|
self._run(["git", "remote", "remove", remote_name])
|
|
@@ -418,6 +476,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
418
476
|
return False
|
|
419
477
|
|
|
420
478
|
def _git_rev_parse_verify(self, revision: str) -> list[str]:
|
|
479
|
+
"""Verify and return the parsed reference for a git revision."""
|
|
421
480
|
stdout: list[str] = []
|
|
422
481
|
error = False
|
|
423
482
|
try:
|
|
@@ -434,6 +493,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
434
493
|
return stdout
|
|
435
494
|
|
|
436
495
|
def _repo_fix_branch(self) -> bool:
|
|
496
|
+
"""Check out the correct revision branch if current branch differs."""
|
|
437
497
|
git_ref_after_merge = self._git_ref(cwd=self.git_local_dir)
|
|
438
498
|
branch_changed = False
|
|
439
499
|
if self["revision"]:
|
|
@@ -441,24 +501,35 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
441
501
|
# returns a different commit revision
|
|
442
502
|
git_tags, _ = self._run(["git", "tag", "--points-at", "HEAD"])
|
|
443
503
|
|
|
504
|
+
is_branch = False
|
|
444
505
|
# Also check the commit revision in case
|
|
445
506
|
# branch is a commit revision instead of a tag
|
|
446
507
|
try:
|
|
447
|
-
# Check if the branch exists
|
|
508
|
+
# Check if the branch exists remotely
|
|
448
509
|
git_ref_branch = self._git_rev_parse_verify("origin/"
|
|
449
510
|
+ self["revision"]
|
|
450
511
|
+ "^{commit}")[0]
|
|
512
|
+
is_branch = True
|
|
451
513
|
except GitRevisionDoesNotExist:
|
|
452
514
|
# Check if the commit ref exists
|
|
453
515
|
try:
|
|
454
516
|
git_ref_branch = \
|
|
455
517
|
self._git_rev_parse_verify(self["revision"])[0]
|
|
518
|
+
if self._git_is_local_branch(self["revision"]):
|
|
519
|
+
is_branch = True
|
|
456
520
|
except GitRevisionDoesNotExist as err:
|
|
457
521
|
raise BatchFetchError(f"The branch '{self['revision']}' "
|
|
458
522
|
"does not exist.") from err
|
|
459
523
|
|
|
460
|
-
|
|
461
|
-
|
|
524
|
+
needs_checkout = False
|
|
525
|
+
if self.current_branch != self["revision"]:
|
|
526
|
+
if is_branch:
|
|
527
|
+
needs_checkout = True
|
|
528
|
+
elif git_ref_after_merge != git_ref_branch and \
|
|
529
|
+
self["revision"] not in git_tags:
|
|
530
|
+
needs_checkout = True
|
|
531
|
+
|
|
532
|
+
if needs_checkout:
|
|
462
533
|
# Update the branch
|
|
463
534
|
self._run(["git", "checkout"] + [self["revision"]])
|
|
464
535
|
self.add_output(self.indent_spaces
|
|
@@ -473,6 +544,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
473
544
|
return branch_changed
|
|
474
545
|
|
|
475
546
|
def _git_fetch_origin(self) -> None:
|
|
547
|
+
"""Execute `git fetch origin` if it has not already been done."""
|
|
476
548
|
# Fetch
|
|
477
549
|
if not self._git_fetch_origin_done:
|
|
478
550
|
cmd = ["git", "fetch", "origin"]
|
|
@@ -480,6 +552,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
480
552
|
self._git_fetch_origin_done = True
|
|
481
553
|
|
|
482
554
|
def _repo_fix_remote_origin(self) -> None:
|
|
555
|
+
"""Ensure remote origin URL matches configuration and upstream sets."""
|
|
483
556
|
correct_origin_url = self[self.main_key]
|
|
484
557
|
update_remote_origin = False
|
|
485
558
|
|
|
@@ -491,6 +564,15 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
491
564
|
update_remote_origin = True
|
|
492
565
|
|
|
493
566
|
if update_remote_origin:
|
|
567
|
+
# We must fetch the upstream branch BEFORE updating the remote,
|
|
568
|
+
# because removing the remote drops the tracking configuration.
|
|
569
|
+
upstream_branch = None
|
|
570
|
+
if self.current_branch:
|
|
571
|
+
try:
|
|
572
|
+
upstream_branch = self._get_upstream_branch()
|
|
573
|
+
except BatchFetchError:
|
|
574
|
+
pass
|
|
575
|
+
|
|
494
576
|
# Update remote
|
|
495
577
|
try:
|
|
496
578
|
self._git_set_remote_url(correct_origin_url)
|
|
@@ -501,17 +583,20 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
501
583
|
# Get the current branch
|
|
502
584
|
if self.current_branch:
|
|
503
585
|
try:
|
|
586
|
+
target_upstream = (upstream_branch if upstream_branch
|
|
587
|
+
else f"origin/{self.current_branch}")
|
|
588
|
+
|
|
504
589
|
self.add_output(
|
|
505
590
|
self.indent_spaces
|
|
506
591
|
+ "[INFO] Git fetch origin reason: "
|
|
507
592
|
f"we need to set the upstream "
|
|
508
|
-
f"origin to {
|
|
593
|
+
f"origin to {target_upstream}"
|
|
509
594
|
"\n"
|
|
510
595
|
)
|
|
511
596
|
self._git_fetch_origin()
|
|
512
597
|
|
|
513
598
|
cmd = ["git", "branch",
|
|
514
|
-
f"--set-upstream-to=
|
|
599
|
+
f"--set-upstream-to={target_upstream}"]
|
|
515
600
|
_, _ = self._run(cmd)
|
|
516
601
|
# TODO: handle errors
|
|
517
602
|
except subprocess.CalledProcessError as err:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: batchfetch
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.8
|
|
4
4
|
Summary: Efficiently clone and pull multiple Git repositories.
|
|
5
5
|
Home-page: https://github.com/jamescherti/batchfetch
|
|
6
6
|
Author: James Cherti
|
|
@@ -80,7 +80,7 @@ tasks:
|
|
|
80
80
|
delete: true
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
Execute the `batchfetch` command from the same directory as `batchfetch.
|
|
83
|
+
Execute the `batchfetch` command from the same directory as `batchfetch.yaml` to make it clone or update the local copies of the repositories above.
|
|
84
84
|
|
|
85
85
|
## Command-line options
|
|
86
86
|
|
|
@@ -94,7 +94,7 @@ Efficiently clone/pull multiple Git repositories in parallel.
|
|
|
94
94
|
positional arguments:
|
|
95
95
|
target This is a target path that batchfetch is supposed to
|
|
96
96
|
handle. When no target is specified, execute the tasks
|
|
97
|
-
of all target paths defined in the batchfetch.
|
|
97
|
+
of all target paths defined in the batchfetch.yaml list
|
|
98
98
|
of tasks.
|
|
99
99
|
|
|
100
100
|
options:
|
|
@@ -173,20 +173,6 @@ tasks:
|
|
|
173
173
|
revision: b9c6d9b6134b4981760893254f804a371ffbc899
|
|
174
174
|
```
|
|
175
175
|
|
|
176
|
-
### How to configure additional Git remotes?
|
|
177
|
-
|
|
178
|
-
You can define additional remotes for a Git repository using the `remote` option. Pass a list of key-value pairs where the key is the remote name and the value is the corresponding URL. Batchfetch will automatically ensure these remotes are present and point to the correct URLs.
|
|
179
|
-
|
|
180
|
-
```yaml
|
|
181
|
-
---
|
|
182
|
-
tasks:
|
|
183
|
-
- git: https://github.com/jamescherti/easysession.el
|
|
184
|
-
path: emacs/easysession
|
|
185
|
-
remote:
|
|
186
|
-
upstream: https://github.com/name/repo
|
|
187
|
-
upstream2: https://github.com/name/repo2
|
|
188
|
-
```
|
|
189
|
-
|
|
190
176
|
### How to customize Git clone, merge, and update strategies?
|
|
191
177
|
|
|
192
178
|
Batchfetch allows you to define advanced options for interacting with Git repositories. These parameters can be declared globally under the `options` block to apply to all tasks, or individually within a specific task to override the global defaults.
|
|
@@ -219,6 +205,20 @@ tasks:
|
|
|
219
205
|
|
|
220
206
|
```
|
|
221
207
|
|
|
208
|
+
### How to configure additional Git remotes?
|
|
209
|
+
|
|
210
|
+
You can define additional remotes for a Git repository using the `remote` option. Pass a list of key-value pairs where the key is the remote name and the value is the corresponding URL. Batchfetch will automatically ensure these remotes are present and point to the correct URLs.
|
|
211
|
+
|
|
212
|
+
```yaml
|
|
213
|
+
---
|
|
214
|
+
tasks:
|
|
215
|
+
- git: https://github.com/jamescherti/easysession.el
|
|
216
|
+
path: emacs/easysession
|
|
217
|
+
remote:
|
|
218
|
+
upstream: https://github.com/name/repo
|
|
219
|
+
upstream2: https://github.com/name/repo2
|
|
220
|
+
```
|
|
221
|
+
|
|
222
222
|
### How to execute a command before and after a task?
|
|
223
223
|
|
|
224
224
|
To execute a command both before and after a specific task, you can define the `exec_before` and `exec_after` directives within the task configuration. These directives specify commands to be executed at the respective stages of the task lifecycle.
|
|
@@ -236,9 +236,9 @@ tasks:
|
|
|
236
236
|
|
|
237
237
|
### How to make batchfetch handle only one path?
|
|
238
238
|
|
|
239
|
-
To configure `batchfetch` to handle a specific path, you can define your tasks in a `batchfetch.
|
|
239
|
+
To configure `batchfetch` to handle a specific path, you can define your tasks in a `batchfetch.yaml` file and pass the desired path as an argument to the `batchfetch` command.
|
|
240
240
|
|
|
241
|
-
#### Example `batchfetch.
|
|
241
|
+
#### Example `batchfetch.yaml` file:
|
|
242
242
|
|
|
243
243
|
In the following example, the `easysession` task clones two Git repositories:
|
|
244
244
|
|
|
@@ -258,7 +258,7 @@ To make `batchfetch` clone only `easysession`, pass its path as an argument:
|
|
|
258
258
|
batchfetch easysession
|
|
259
259
|
```
|
|
260
260
|
|
|
261
|
-
This will execute only the task corresponding to the `easysession` path, skipping all others in the `batchfetch.
|
|
261
|
+
This will execute only the task corresponding to the `easysession` path, skipping all others in the `batchfetch.yaml` file.
|
|
262
262
|
|
|
263
263
|
### How can I configure batchfetch to load a file other than batchfetch.yaml?
|
|
264
264
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|