batchfetch 1.3.6__tar.gz → 1.3.7__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.6 → batchfetch-1.3.7}/PKG-INFO +55 -5
- {batchfetch-1.3.6 → batchfetch-1.3.7}/README.md +54 -4
- {batchfetch-1.3.6 → batchfetch-1.3.7}/batchfetch/__init__.py +1 -1
- {batchfetch-1.3.6 → batchfetch-1.3.7}/batchfetch/batchfetch_base.py +30 -28
- {batchfetch-1.3.6 → batchfetch-1.3.7}/batchfetch/batchfetch_cli.py +36 -30
- {batchfetch-1.3.6 → batchfetch-1.3.7}/batchfetch/batchfetch_git.py +114 -42
- {batchfetch-1.3.6 → batchfetch-1.3.7}/batchfetch/helpers.py +24 -22
- {batchfetch-1.3.6 → batchfetch-1.3.7}/batchfetch.egg-info/PKG-INFO +55 -5
- {batchfetch-1.3.6 → batchfetch-1.3.7}/setup.py +2 -2
- {batchfetch-1.3.6 → batchfetch-1.3.7}/tests/test_helpers.py +1 -1
- {batchfetch-1.3.6 → batchfetch-1.3.7}/LICENSE +0 -0
- {batchfetch-1.3.6 → batchfetch-1.3.7}/batchfetch.egg-info/SOURCES.txt +0 -0
- {batchfetch-1.3.6 → batchfetch-1.3.7}/batchfetch.egg-info/dependency_links.txt +0 -0
- {batchfetch-1.3.6 → batchfetch-1.3.7}/batchfetch.egg-info/entry_points.txt +0 -0
- {batchfetch-1.3.6 → batchfetch-1.3.7}/batchfetch.egg-info/requires.txt +0 -0
- {batchfetch-1.3.6 → batchfetch-1.3.7}/batchfetch.egg-info/top_level.txt +0 -0
- {batchfetch-1.3.6 → batchfetch-1.3.7}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: batchfetch
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.7
|
|
4
4
|
Summary: Efficiently clone and pull multiple Git repositories.
|
|
5
5
|
Home-page: https://github.com/jamescherti/batchfetch
|
|
6
6
|
Author: James Cherti
|
|
@@ -117,6 +117,7 @@ options:
|
|
|
117
117
|
```
|
|
118
118
|
|
|
119
119
|
## Features
|
|
120
|
+
|
|
120
121
|
- Git Clone and Fetch/Merge: Clones the repositories and their submodules, ensuring that all the repositories are always up-to-date by fetching and merging changes.
|
|
121
122
|
- Parallel Operations: Utilizes threads to simultaneously Git clone or pull multiple repositories, dramatically reducing wait times.
|
|
122
123
|
- User-Friendly Interface: Provides simple and straightforward command-line options that make it easy to get started and effectively manage your repositories.
|
|
@@ -135,7 +136,7 @@ When *batchfetch* encounters an untracked file, it displays an error message to
|
|
|
135
136
|
|
|
136
137
|
Here is an example of a *batchfetch.yaml* file that enables *batchfetch* to accept a list of untracked files:
|
|
137
138
|
|
|
138
|
-
```
|
|
139
|
+
```yaml
|
|
139
140
|
options:
|
|
140
141
|
ignore_untracked:
|
|
141
142
|
- ./test
|
|
@@ -161,7 +162,8 @@ Batchfetch is fast, not only because it runs Git commands in parallel, but also
|
|
|
161
162
|
When the user has specifies a revision (branch or commit reference), Batchfetch only performs a `git fetch` if that revision does not exist locally. If the revision is already up to date, it simply proceeds to the next repository in the queue.
|
|
162
163
|
|
|
163
164
|
That's why it is highly recommended to always specify the revision to speed up Batchfetch, if speed is important to you. Here is an example of a `batchfetch.yaml` file where the branch (`1.1.0`) or commit reference (`b9c6d9b6134b4981760893254f804a371ffbc899`) is specified:
|
|
164
|
-
|
|
165
|
+
|
|
166
|
+
```yaml
|
|
165
167
|
tasks:
|
|
166
168
|
- git: https://github.com/jamescherti/outline-indent.el
|
|
167
169
|
revision: "1.1.0"
|
|
@@ -171,12 +173,59 @@ tasks:
|
|
|
171
173
|
revision: b9c6d9b6134b4981760893254f804a371ffbc899
|
|
172
174
|
```
|
|
173
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
|
+
### How to customize Git clone, merge, and update strategies?
|
|
191
|
+
|
|
192
|
+
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.
|
|
193
|
+
|
|
194
|
+
* `git_clone_args`: A list of extra arguments to pass to `git clone`.
|
|
195
|
+
* `git_merge_args`: A list of extra arguments to pass to `git merge`.
|
|
196
|
+
* `git_update_strategy`: Specifies how updates should be applied. Valid options are `"merge"` (default), `"rebase"`, or `"reset"`.
|
|
197
|
+
* `git_pull`: A boolean indicating whether to run Git pull/fetch operations. Set to `false` to disable updating entirely (default: `true`).
|
|
198
|
+
|
|
199
|
+
Here is an example demonstrating both global and local configurations:
|
|
200
|
+
|
|
201
|
+
```yaml
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
options:
|
|
205
|
+
# Apply these defaults to all tasks
|
|
206
|
+
git_update_strategy: reset
|
|
207
|
+
git_clone_args:
|
|
208
|
+
- --recurse-submodules
|
|
209
|
+
|
|
210
|
+
tasks:
|
|
211
|
+
- git: https://github.com/jamescherti/compile-angel.el
|
|
212
|
+
git_pull: false # Do not attempt to update this repo
|
|
213
|
+
|
|
214
|
+
- git: https://github.com/jamescherti/outline-indent.el
|
|
215
|
+
# Override global strategy specifically for this task
|
|
216
|
+
git_update_strategy: merge
|
|
217
|
+
git_merge_args:
|
|
218
|
+
- --no-ff
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
|
|
174
222
|
### How to execute a command before and after a task?
|
|
175
223
|
|
|
176
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.
|
|
177
225
|
|
|
178
226
|
Here is an example:
|
|
179
|
-
|
|
227
|
+
|
|
228
|
+
```yaml
|
|
180
229
|
---
|
|
181
230
|
tasks:
|
|
182
231
|
- git: https://github.com/jamescherti/easysession.el
|
|
@@ -192,6 +241,7 @@ To configure `batchfetch` to handle a specific path, you can define your tasks i
|
|
|
192
241
|
#### Example `batchfetch.yml` file:
|
|
193
242
|
|
|
194
243
|
In the following example, the `easysession` task clones two Git repositories:
|
|
244
|
+
|
|
195
245
|
```yaml
|
|
196
246
|
---
|
|
197
247
|
tasks:
|
|
@@ -227,7 +277,7 @@ batchfetch
|
|
|
227
277
|
|
|
228
278
|
## License
|
|
229
279
|
|
|
230
|
-
Copyright (C) 2024-
|
|
280
|
+
Copyright (C) 2024-2026 [James Cherti](https://www.jamescherti.com)
|
|
231
281
|
|
|
232
282
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
233
283
|
|
|
@@ -83,6 +83,7 @@ options:
|
|
|
83
83
|
```
|
|
84
84
|
|
|
85
85
|
## Features
|
|
86
|
+
|
|
86
87
|
- Git Clone and Fetch/Merge: Clones the repositories and their submodules, ensuring that all the repositories are always up-to-date by fetching and merging changes.
|
|
87
88
|
- Parallel Operations: Utilizes threads to simultaneously Git clone or pull multiple repositories, dramatically reducing wait times.
|
|
88
89
|
- User-Friendly Interface: Provides simple and straightforward command-line options that make it easy to get started and effectively manage your repositories.
|
|
@@ -101,7 +102,7 @@ When *batchfetch* encounters an untracked file, it displays an error message to
|
|
|
101
102
|
|
|
102
103
|
Here is an example of a *batchfetch.yaml* file that enables *batchfetch* to accept a list of untracked files:
|
|
103
104
|
|
|
104
|
-
```
|
|
105
|
+
```yaml
|
|
105
106
|
options:
|
|
106
107
|
ignore_untracked:
|
|
107
108
|
- ./test
|
|
@@ -127,7 +128,8 @@ Batchfetch is fast, not only because it runs Git commands in parallel, but also
|
|
|
127
128
|
When the user has specifies a revision (branch or commit reference), Batchfetch only performs a `git fetch` if that revision does not exist locally. If the revision is already up to date, it simply proceeds to the next repository in the queue.
|
|
128
129
|
|
|
129
130
|
That's why it is highly recommended to always specify the revision to speed up Batchfetch, if speed is important to you. Here is an example of a `batchfetch.yaml` file where the branch (`1.1.0`) or commit reference (`b9c6d9b6134b4981760893254f804a371ffbc899`) is specified:
|
|
130
|
-
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
131
133
|
tasks:
|
|
132
134
|
- git: https://github.com/jamescherti/outline-indent.el
|
|
133
135
|
revision: "1.1.0"
|
|
@@ -137,12 +139,59 @@ tasks:
|
|
|
137
139
|
revision: b9c6d9b6134b4981760893254f804a371ffbc899
|
|
138
140
|
```
|
|
139
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
|
+
### How to customize Git clone, merge, and update strategies?
|
|
157
|
+
|
|
158
|
+
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.
|
|
159
|
+
|
|
160
|
+
* `git_clone_args`: A list of extra arguments to pass to `git clone`.
|
|
161
|
+
* `git_merge_args`: A list of extra arguments to pass to `git merge`.
|
|
162
|
+
* `git_update_strategy`: Specifies how updates should be applied. Valid options are `"merge"` (default), `"rebase"`, or `"reset"`.
|
|
163
|
+
* `git_pull`: A boolean indicating whether to run Git pull/fetch operations. Set to `false` to disable updating entirely (default: `true`).
|
|
164
|
+
|
|
165
|
+
Here is an example demonstrating both global and local configurations:
|
|
166
|
+
|
|
167
|
+
```yaml
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
options:
|
|
171
|
+
# Apply these defaults to all tasks
|
|
172
|
+
git_update_strategy: reset
|
|
173
|
+
git_clone_args:
|
|
174
|
+
- --recurse-submodules
|
|
175
|
+
|
|
176
|
+
tasks:
|
|
177
|
+
- git: https://github.com/jamescherti/compile-angel.el
|
|
178
|
+
git_pull: false # Do not attempt to update this repo
|
|
179
|
+
|
|
180
|
+
- git: https://github.com/jamescherti/outline-indent.el
|
|
181
|
+
# Override global strategy specifically for this task
|
|
182
|
+
git_update_strategy: merge
|
|
183
|
+
git_merge_args:
|
|
184
|
+
- --no-ff
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
|
|
140
188
|
### How to execute a command before and after a task?
|
|
141
189
|
|
|
142
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.
|
|
143
191
|
|
|
144
192
|
Here is an example:
|
|
145
|
-
|
|
193
|
+
|
|
194
|
+
```yaml
|
|
146
195
|
---
|
|
147
196
|
tasks:
|
|
148
197
|
- git: https://github.com/jamescherti/easysession.el
|
|
@@ -158,6 +207,7 @@ To configure `batchfetch` to handle a specific path, you can define your tasks i
|
|
|
158
207
|
#### Example `batchfetch.yml` file:
|
|
159
208
|
|
|
160
209
|
In the following example, the `easysession` task clones two Git repositories:
|
|
210
|
+
|
|
161
211
|
```yaml
|
|
162
212
|
---
|
|
163
213
|
tasks:
|
|
@@ -193,7 +243,7 @@ batchfetch
|
|
|
193
243
|
|
|
194
244
|
## License
|
|
195
245
|
|
|
196
|
-
Copyright (C) 2024-
|
|
246
|
+
Copyright (C) 2024-2026 [James Cherti](https://www.jamescherti.com)
|
|
197
247
|
|
|
198
248
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
199
249
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python
|
|
2
2
|
#
|
|
3
|
-
# Copyright (C) 2024-
|
|
3
|
+
# Copyright (C) 2024-2026 James Cherti
|
|
4
4
|
# URL: https://github.com/jamescherti/batchfetch
|
|
5
5
|
#
|
|
6
6
|
# This program is free software: you can redistribute it and/or modify it under
|
|
@@ -16,12 +16,14 @@
|
|
|
16
16
|
# You should have received a copy of the GNU General Public License along with
|
|
17
17
|
# this program. If not, see <https://www.gnu.org/licenses/>.
|
|
18
18
|
#
|
|
19
|
-
"Base class used to run tasks in parallel."
|
|
19
|
+
"""Base class used to run tasks in parallel."""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
20
22
|
|
|
21
23
|
import os
|
|
22
24
|
from copy import deepcopy
|
|
23
25
|
from pathlib import Path
|
|
24
|
-
from typing import Any
|
|
26
|
+
from typing import Any
|
|
25
27
|
|
|
26
28
|
from schema import Optional, Or, Schema
|
|
27
29
|
|
|
@@ -37,21 +39,21 @@ class DataAlreadyInitialized(Exception):
|
|
|
37
39
|
|
|
38
40
|
|
|
39
41
|
class TaskBase:
|
|
40
|
-
def __init__(self, data:
|
|
41
|
-
self.global_options_schema:
|
|
42
|
-
self.global_options_values:
|
|
42
|
+
def __init__(self, data: dict[str, Any], options: dict[str, Any]) -> None:
|
|
43
|
+
self.global_options_schema: dict[Any, Any] = {}
|
|
44
|
+
self.global_options_values: dict[str, Any] = options
|
|
43
45
|
|
|
44
|
-
self.task_schema:
|
|
45
|
-
self.task_default_values:
|
|
46
|
+
self.task_schema: dict[Any, Any] = {}
|
|
47
|
+
self.task_default_values: dict[str, Any] = {}
|
|
46
48
|
|
|
47
49
|
self._item_values = data
|
|
48
50
|
|
|
49
51
|
# Variables
|
|
50
|
-
self.values:
|
|
51
|
-
self.options:
|
|
52
|
+
self.values: dict[str, Any] = {}
|
|
53
|
+
self.options: dict[str, Any] = {}
|
|
52
54
|
self._values_initialized = False
|
|
53
55
|
|
|
54
|
-
def _initialize_data(self):
|
|
56
|
+
def _initialize_data(self) -> None:
|
|
55
57
|
if self._values_initialized:
|
|
56
58
|
raise DataAlreadyInitialized
|
|
57
59
|
|
|
@@ -76,10 +78,10 @@ class TaskBase:
|
|
|
76
78
|
"changed": False,
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
def validate_schema(self):
|
|
81
|
+
def validate_schema(self) -> None:
|
|
80
82
|
self._initialize_data()
|
|
81
83
|
|
|
82
|
-
def __getitem__(self, key):
|
|
84
|
+
def __getitem__(self, key: str) -> Any:
|
|
83
85
|
self._initialize_data()
|
|
84
86
|
|
|
85
87
|
if key in self.values:
|
|
@@ -94,8 +96,8 @@ class TaskBase:
|
|
|
94
96
|
class TaskBatchFetch(TaskBase):
|
|
95
97
|
"""Plugin downloader base class."""
|
|
96
98
|
|
|
97
|
-
def __init__(self, data:
|
|
98
|
-
new_options:
|
|
99
|
+
def __init__(self, data: dict[str, Any], options: dict[str, Any]) -> None:
|
|
100
|
+
new_options: dict[str, Any] = {"exec_before": [],
|
|
99
101
|
"exec_after": [],
|
|
100
102
|
"ignore_untracked": []}
|
|
101
103
|
new_options.update(options)
|
|
@@ -107,13 +109,13 @@ class TaskBatchFetch(TaskBase):
|
|
|
107
109
|
self.env = os.environ.copy()
|
|
108
110
|
|
|
109
111
|
# Default
|
|
110
|
-
self.global_options_schema:
|
|
112
|
+
self.global_options_schema: dict[Any, Any] = {
|
|
111
113
|
Optional("exec_before"): Or([str], str),
|
|
112
114
|
Optional("exec_after"): Or([str], str),
|
|
113
115
|
Optional("ignore_untracked"): Or([str], str),
|
|
114
116
|
}
|
|
115
117
|
|
|
116
|
-
self.task_schema:
|
|
118
|
+
self.task_schema: dict[Any, Any] = {
|
|
117
119
|
Optional("path"): str,
|
|
118
120
|
Optional("delete"): bool,
|
|
119
121
|
|
|
@@ -124,12 +126,12 @@ class TaskBatchFetch(TaskBase):
|
|
|
124
126
|
Optional("ignore_untracked"): Or([str], str),
|
|
125
127
|
}
|
|
126
128
|
|
|
127
|
-
self.task_default_values:
|
|
129
|
+
self.task_default_values: dict[str, Any] = {
|
|
128
130
|
# Optional items
|
|
129
131
|
"delete": False,
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
def _initialize_data(self):
|
|
134
|
+
def _initialize_data(self) -> None:
|
|
133
135
|
try:
|
|
134
136
|
super()._initialize_data()
|
|
135
137
|
except DataAlreadyInitialized:
|
|
@@ -138,14 +140,14 @@ class TaskBatchFetch(TaskBase):
|
|
|
138
140
|
# Mark values as initialized
|
|
139
141
|
self._values_initialized = True
|
|
140
142
|
|
|
141
|
-
def _local_task_exec(self, *args, **kwargs):
|
|
143
|
+
def _local_task_exec(self, *args: Any, **kwargs: Any) -> None:
|
|
142
144
|
stdout = run_indent_str(env=self.env, spaces=self.indent,
|
|
143
145
|
*args, **kwargs)
|
|
144
146
|
if not stdout.endswith("\n"):
|
|
145
147
|
stdout += "\n"
|
|
146
148
|
self.add_output(stdout)
|
|
147
149
|
|
|
148
|
-
def _exec_before(self, cwd: os.PathLike = Path(".")):
|
|
150
|
+
def _exec_before(self, cwd: os.PathLike = Path(".")) -> None:
|
|
149
151
|
self._initialize_data()
|
|
150
152
|
if self["delete"]:
|
|
151
153
|
return
|
|
@@ -162,9 +164,9 @@ class TaskBatchFetch(TaskBase):
|
|
|
162
164
|
if cmd:
|
|
163
165
|
self._local_task_exec(cmd, cwd=str(cwd))
|
|
164
166
|
|
|
165
|
-
def _exec_after(self, cwd: os.PathLike = Path(".")):
|
|
167
|
+
def _exec_after(self, cwd: os.PathLike = Path(".")) -> None:
|
|
166
168
|
self._initialize_data()
|
|
167
|
-
if self["delete"] or not self.is_changed():
|
|
169
|
+
if self["delete"] or self.is_error() or not self.is_changed():
|
|
168
170
|
return
|
|
169
171
|
|
|
170
172
|
# Local
|
|
@@ -183,7 +185,7 @@ class TaskBatchFetch(TaskBase):
|
|
|
183
185
|
self._initialize_data()
|
|
184
186
|
return bool(self.values["result"]["changed"])
|
|
185
187
|
|
|
186
|
-
def set_changed(self, changed: bool):
|
|
188
|
+
def set_changed(self, changed: bool) -> None:
|
|
187
189
|
self._initialize_data()
|
|
188
190
|
self.values["result"]["changed"] = changed
|
|
189
191
|
|
|
@@ -197,15 +199,15 @@ class TaskBatchFetch(TaskBase):
|
|
|
197
199
|
self._initialize_data()
|
|
198
200
|
return bool(self.values["result"]["error"])
|
|
199
201
|
|
|
200
|
-
def set_error(self, error: bool):
|
|
202
|
+
def set_error(self, error: bool) -> None:
|
|
201
203
|
self._initialize_data()
|
|
202
204
|
self.values["result"]["error"] = error
|
|
203
205
|
|
|
204
|
-
def set_output(self, output: str):
|
|
206
|
+
def set_output(self, output: str) -> None:
|
|
205
207
|
self._initialize_data()
|
|
206
208
|
self.values["result"]["output"] = output
|
|
207
209
|
|
|
208
|
-
def add_output(self, output: str):
|
|
210
|
+
def add_output(self, output: str) -> None:
|
|
209
211
|
self._initialize_data()
|
|
210
212
|
self.values["result"]["output"] += output
|
|
211
213
|
|
|
@@ -214,6 +216,6 @@ class TaskBatchFetch(TaskBase):
|
|
|
214
216
|
self._initialize_data()
|
|
215
217
|
return str(self.values["result"]["output"])
|
|
216
218
|
|
|
217
|
-
def update(self):
|
|
219
|
+
def update(self) -> dict[str, Any]:
|
|
218
220
|
self._initialize_data()
|
|
219
221
|
return self.values
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python
|
|
2
2
|
#
|
|
3
|
-
# Copyright (C) 2024-
|
|
3
|
+
# Copyright (C) 2024-2026 James Cherti
|
|
4
4
|
# URL: https://github.com/jamescherti/batchfetch
|
|
5
5
|
#
|
|
6
6
|
# This program is free software: you can redistribute it and/or modify it under
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
#
|
|
19
19
|
"""Command line interface."""
|
|
20
20
|
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
21
23
|
import argparse
|
|
22
24
|
import logging
|
|
23
25
|
import os
|
|
@@ -25,7 +27,7 @@ import subprocess
|
|
|
25
27
|
import sys
|
|
26
28
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
27
29
|
from pathlib import Path
|
|
28
|
-
from typing import Any
|
|
30
|
+
from typing import Any
|
|
29
31
|
|
|
30
32
|
import colorama
|
|
31
33
|
import yaml # type: ignore
|
|
@@ -33,35 +35,37 @@ from colorama import Fore
|
|
|
33
35
|
from schema import Optional, Or, Schema, SchemaError
|
|
34
36
|
from setproctitle import setproctitle
|
|
35
37
|
|
|
36
|
-
from .batchfetch_base import BatchFetchError
|
|
38
|
+
from .batchfetch_base import BatchFetchError
|
|
37
39
|
from .batchfetch_git import BatchFetchGit
|
|
38
40
|
from .helpers import collect_parent_dirs
|
|
39
41
|
|
|
40
42
|
|
|
43
|
+
# pylint: disable=too-many-instance-attributes
|
|
41
44
|
class BatchFetchCli:
|
|
42
45
|
"""Command-line-interface that downloads."""
|
|
43
46
|
|
|
47
|
+
# pylint: disable=too-many-positional-arguments
|
|
44
48
|
def __init__(self, max_workers: int, verbose: bool, check_untracked: bool,
|
|
45
|
-
targets:
|
|
49
|
+
targets: list[str], cwd: os.PathLike) -> None:
|
|
46
50
|
os.chdir(cwd)
|
|
47
51
|
self._cwd = cwd
|
|
48
52
|
self._logger = logging.getLogger(self.__class__.__name__)
|
|
49
53
|
self.targets = {Path(item).absolute() for item in targets}
|
|
50
54
|
self.cfg: dict = {}
|
|
51
55
|
self.check_untracked = check_untracked
|
|
52
|
-
self.tracked_paths:
|
|
53
|
-
self.ignore_untracked:
|
|
56
|
+
self.tracked_paths: dict[Path, set[str]] = {}
|
|
57
|
+
self.ignore_untracked: set[Path] = set()
|
|
54
58
|
self.verbose = verbose
|
|
55
59
|
self.max_workers = max_workers
|
|
56
|
-
self.dirs_relative_to_batchfetch:
|
|
60
|
+
self.dirs_relative_to_batchfetch: set[str] = set()
|
|
57
61
|
|
|
58
62
|
# Plugin
|
|
59
|
-
self.batchfetch_schemas:
|
|
60
|
-
self.batchfetch_classes:
|
|
61
|
-
self.cfg_schema:
|
|
63
|
+
self.batchfetch_schemas: dict[Any, Any] = {}
|
|
64
|
+
self.batchfetch_classes: dict[str, Any] = {}
|
|
65
|
+
self.cfg_schema: dict[Any, Any] = {}
|
|
62
66
|
self._plugin_add("git", BatchFetchGit)
|
|
63
67
|
|
|
64
|
-
def _plugin_add(self, keyword: str, batchfetch_class: Any):
|
|
68
|
+
def _plugin_add(self, keyword: str, batchfetch_class: Any) -> None:
|
|
65
69
|
batchfetch_instance = batchfetch_class(data={},
|
|
66
70
|
options={})
|
|
67
71
|
batchfetch_instance.validate_schema()
|
|
@@ -76,7 +80,7 @@ class BatchFetchCli:
|
|
|
76
80
|
]
|
|
77
81
|
}
|
|
78
82
|
|
|
79
|
-
def _plugin_get(self, raw_data: dict) -> str:
|
|
83
|
+
def _plugin_get(self, raw_data: dict[str, Any]) -> str:
|
|
80
84
|
keyword_found = None
|
|
81
85
|
for keyword in self.batchfetch_classes:
|
|
82
86
|
if keyword in raw_data:
|
|
@@ -94,7 +98,8 @@ class BatchFetchCli:
|
|
|
94
98
|
|
|
95
99
|
return keyword_found
|
|
96
100
|
|
|
97
|
-
def load(self, path: Path):
|
|
101
|
+
def load(self, path: Path) -> None:
|
|
102
|
+
# pylint: disable=too-many-try-statements
|
|
98
103
|
try:
|
|
99
104
|
with open(path, "r", encoding="utf-8") as fhandler:
|
|
100
105
|
yaml_dict = yaml.load(fhandler, Loader=yaml.FullLoader)
|
|
@@ -136,7 +141,7 @@ class BatchFetchCli:
|
|
|
136
141
|
except OSError as err:
|
|
137
142
|
raise BatchFetchError(str(err)) from err
|
|
138
143
|
|
|
139
|
-
def _loads(self, data: dict):
|
|
144
|
+
def _loads(self, data: dict[str, Any]) -> None:
|
|
140
145
|
schema = Schema(self.cfg_schema)
|
|
141
146
|
try:
|
|
142
147
|
schema.validate(data)
|
|
@@ -154,11 +159,11 @@ class BatchFetchCli:
|
|
|
154
159
|
|
|
155
160
|
self._loads_tasks(data)
|
|
156
161
|
|
|
157
|
-
def _loads_tasks(self, data: dict):
|
|
162
|
+
def _loads_tasks(self, data: dict[str, Any]) -> None:
|
|
158
163
|
if "tasks" not in data:
|
|
159
164
|
return
|
|
160
165
|
|
|
161
|
-
dict_local_dir = {} # type: ignore
|
|
166
|
+
dict_local_dir: dict[str, Any] = {} # type: ignore
|
|
162
167
|
for task in data["tasks"]:
|
|
163
168
|
keyword = self._plugin_get(task)
|
|
164
169
|
batchfetch_class = self.batchfetch_classes[keyword]
|
|
@@ -183,6 +188,8 @@ class BatchFetchCli:
|
|
|
183
188
|
|
|
184
189
|
dict_local_dir[str(dest_path)] = batchfetch_instance[keyword]
|
|
185
190
|
|
|
191
|
+
# pylint: disable=too-many-branches
|
|
192
|
+
# pylint: disable=too-many-statements
|
|
186
193
|
def run_tasks(self) -> bool:
|
|
187
194
|
failed = []
|
|
188
195
|
error = False
|
|
@@ -192,6 +199,7 @@ class BatchFetchCli:
|
|
|
192
199
|
|
|
193
200
|
executor_update = ThreadPoolExecutor(max_workers=self.max_workers)
|
|
194
201
|
|
|
202
|
+
# pylint: disable=too-many-try-statements
|
|
195
203
|
try:
|
|
196
204
|
self.dirs_relative_to_batchfetch = set()
|
|
197
205
|
|
|
@@ -209,8 +217,7 @@ class BatchFetchCli:
|
|
|
209
217
|
|
|
210
218
|
targets_not_found = self.targets - targets_executed
|
|
211
219
|
if targets_not_found:
|
|
212
|
-
err_str =
|
|
213
|
-
f"Error: Target(s) not found: "
|
|
220
|
+
err_str = "Error: Target(s) not found: "
|
|
214
221
|
err_str += ", ".join([str(item)
|
|
215
222
|
for item in targets_not_found])
|
|
216
223
|
print(f"Error: {err_str}", file=sys.stderr)
|
|
@@ -282,14 +289,13 @@ class BatchFetchCli:
|
|
|
282
289
|
|
|
283
290
|
return True
|
|
284
291
|
|
|
285
|
-
def _find_untracked_paths(self):
|
|
286
|
-
"Find the files that are untracked and should be deleted."
|
|
292
|
+
def _find_untracked_paths(self) -> None:
|
|
293
|
+
"""Find the files that are untracked and should be deleted."""
|
|
287
294
|
untracked_paths = set()
|
|
288
295
|
cwd = Path.cwd()
|
|
289
|
-
local_ignore_untracked = set()
|
|
296
|
+
local_ignore_untracked: set[Path] = set()
|
|
290
297
|
|
|
291
298
|
for tracked_dir, tracked_filenames in self.tracked_paths.items():
|
|
292
|
-
actual_filenames = {file.name for file in tracked_dir.iterdir()}
|
|
293
299
|
self.ignore_untracked |= {tracked_dir}
|
|
294
300
|
parents = collect_parent_dirs(cwd, tracked_dir)
|
|
295
301
|
local_ignore_untracked |= parents
|
|
@@ -311,7 +317,7 @@ class BatchFetchCli:
|
|
|
311
317
|
for path in untracked_paths:
|
|
312
318
|
err_str += (" - " +
|
|
313
319
|
str(path.relative_to(self._cwd)) +
|
|
314
|
-
(
|
|
320
|
+
("/" if path.is_dir() else "") +
|
|
315
321
|
"\n")
|
|
316
322
|
err_str += ("The paths above are not managed by batchfetch."
|
|
317
323
|
" To retain them, add them to the "
|
|
@@ -320,7 +326,7 @@ class BatchFetchCli:
|
|
|
320
326
|
raise BatchFetchError(err_str)
|
|
321
327
|
|
|
322
328
|
|
|
323
|
-
def parse_args():
|
|
329
|
+
def parse_args() -> argparse.Namespace:
|
|
324
330
|
"""Parse the command line arguments."""
|
|
325
331
|
# Batchfetch file
|
|
326
332
|
try:
|
|
@@ -330,20 +336,19 @@ def parse_args():
|
|
|
330
336
|
|
|
331
337
|
# Jobs
|
|
332
338
|
try:
|
|
333
|
-
|
|
339
|
+
jobs_env = os.environ["BATCHFETCH_JOBS"]
|
|
334
340
|
except KeyError:
|
|
335
341
|
jobs = 5
|
|
336
342
|
else:
|
|
337
|
-
|
|
338
|
-
jobs = int(jobs)
|
|
343
|
+
jobs = int(jobs_env)
|
|
339
344
|
|
|
340
345
|
# Check untracked
|
|
341
346
|
try:
|
|
342
|
-
|
|
347
|
+
check_untracked_env = os.environ["BATCHFETCH_CHECK_UNTRACKED"]
|
|
343
348
|
except KeyError:
|
|
344
349
|
check_untracked = False
|
|
345
350
|
else:
|
|
346
|
-
check_untracked = bool(
|
|
351
|
+
check_untracked = bool(check_untracked_env)
|
|
347
352
|
|
|
348
353
|
desc = "Efficiently clone/pull multiple Git repositories in parallel."
|
|
349
354
|
usage = "%(prog)s [--option] [TARGET]"
|
|
@@ -415,8 +420,9 @@ def parse_args():
|
|
|
415
420
|
return args
|
|
416
421
|
|
|
417
422
|
|
|
418
|
-
def command_line_interface():
|
|
423
|
+
def command_line_interface() -> None:
|
|
419
424
|
"""Command line interface."""
|
|
425
|
+
# pylint: disable=too-many-try-statements
|
|
420
426
|
try:
|
|
421
427
|
errno = 0
|
|
422
428
|
logging.basicConfig(level=logging.INFO, stream=sys.stdout,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python
|
|
2
2
|
#
|
|
3
|
-
# Copyright (C) 2024-
|
|
3
|
+
# Copyright (C) 2024-2026 James Cherti
|
|
4
4
|
# URL: https://github.com/jamescherti/batchfetch
|
|
5
5
|
#
|
|
6
6
|
# This program is free software: you can redistribute it and/or modify it under
|
|
@@ -16,17 +16,18 @@
|
|
|
16
16
|
# You should have received a copy of the GNU General Public License along with
|
|
17
17
|
# this program. If not, see <https://www.gnu.org/licenses/>.
|
|
18
18
|
#
|
|
19
|
-
"Clone and update Git repositories."
|
|
19
|
+
"""Clone and update Git repositories."""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
20
22
|
|
|
21
23
|
import os
|
|
22
|
-
import posixpath
|
|
23
24
|
import shutil
|
|
24
25
|
import subprocess
|
|
25
26
|
import textwrap
|
|
26
|
-
from pathlib import Path
|
|
27
|
-
from typing import
|
|
27
|
+
from pathlib import Path, PurePosixPath
|
|
28
|
+
from typing import Any, Union
|
|
28
29
|
|
|
29
|
-
from schema import Optional
|
|
30
|
+
from schema import Optional, Or
|
|
30
31
|
|
|
31
32
|
from .batchfetch_base import BatchFetchError, TaskBatchFetch
|
|
32
33
|
from .helpers import run_simple
|
|
@@ -43,7 +44,7 @@ class GitRemoteError(Exception):
|
|
|
43
44
|
class BatchFetchGit(TaskBatchFetch):
|
|
44
45
|
"""Clone or update a Git repository."""
|
|
45
46
|
|
|
46
|
-
def __init__(self, *args, **kwargs):
|
|
47
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
47
48
|
self._git_fetch_origin_done = False
|
|
48
49
|
|
|
49
50
|
self.branch_commit_ref = None
|
|
@@ -59,47 +60,53 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
59
60
|
# Local options
|
|
60
61
|
self.main_key: str,
|
|
61
62
|
Optional("revision"): str,
|
|
63
|
+
Optional("remote"): {Optional(str): str},
|
|
62
64
|
|
|
63
65
|
# Same as global options
|
|
64
66
|
Optional("git_clone_args"): [str],
|
|
67
|
+
Optional("git_merge_args"): [str],
|
|
65
68
|
Optional("git_pull"): bool,
|
|
69
|
+
Optional("git_update_strategy"): Or("merge", "rebase", "reset"),
|
|
66
70
|
})
|
|
67
71
|
|
|
68
72
|
self.global_options_schema.update({
|
|
69
73
|
# Global options
|
|
70
74
|
Optional("git_clone_args"): [str],
|
|
75
|
+
Optional("git_merge_args"): [str],
|
|
71
76
|
Optional("git_pull"): bool,
|
|
77
|
+
Optional("git_update_strategy"): Or("merge", "rebase", "reset"),
|
|
72
78
|
})
|
|
73
79
|
|
|
74
80
|
# Data
|
|
75
81
|
self.global_options_values.update({"git_clone_args": [],
|
|
82
|
+
"git_merge_args": [],
|
|
76
83
|
"git_pull": True})
|
|
77
84
|
|
|
78
85
|
self.task_default_values.update({
|
|
79
86
|
self.main_key: "",
|
|
80
87
|
"revision": "",
|
|
81
88
|
"delete": False,
|
|
89
|
+
"remote": {},
|
|
82
90
|
})
|
|
83
91
|
|
|
84
92
|
self.git_local_dir = Path(self["path"])
|
|
85
93
|
self.current_branch = None
|
|
86
94
|
self.current_commit_ref = None
|
|
87
95
|
|
|
88
|
-
def _initialize_data(self):
|
|
96
|
+
def _initialize_data(self) -> None:
|
|
89
97
|
super()._initialize_data()
|
|
90
98
|
|
|
91
99
|
self.values[self.main_key] = \
|
|
92
100
|
self.values[self.main_key].rstrip("/")
|
|
93
101
|
|
|
94
102
|
if "path" not in self.values:
|
|
95
|
-
path =
|
|
96
|
-
posixpath.basename(self.values[self.main_key]) # type: ignore
|
|
103
|
+
path = PurePosixPath(self.values[self.main_key]).name
|
|
97
104
|
# Remove .git from the file name
|
|
98
105
|
if path.endswith(".git"):
|
|
99
106
|
path = path[:-4]
|
|
100
107
|
self.values["path"] = path
|
|
101
108
|
|
|
102
|
-
def update(self):
|
|
109
|
+
def update(self) -> dict[str, Any]:
|
|
103
110
|
"""Clone or update a Git repository."""
|
|
104
111
|
super().update()
|
|
105
112
|
|
|
@@ -117,8 +124,9 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
117
124
|
|
|
118
125
|
self.add_output(f"[GIT {update_type}] {self[self.main_key]}"
|
|
119
126
|
+ (f" (Ref: {self['revision']})"
|
|
120
|
-
|
|
127
|
+
if self["revision"] else "") + "\n")
|
|
121
128
|
|
|
129
|
+
# pylint: disable=too-many-try-statements
|
|
122
130
|
try:
|
|
123
131
|
# Delete
|
|
124
132
|
if self["delete"]:
|
|
@@ -137,6 +145,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
137
145
|
self._update_current_branch_name()
|
|
138
146
|
|
|
139
147
|
self._repo_fix_remote_origin()
|
|
148
|
+
self._repo_fix_remotes()
|
|
140
149
|
self._exec_before(cwd=self.git_local_dir)
|
|
141
150
|
|
|
142
151
|
if not self["revision"]:
|
|
@@ -148,19 +157,19 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
148
157
|
)
|
|
149
158
|
|
|
150
159
|
self.add_output(self.indent_spaces +
|
|
151
|
-
"[INFO]
|
|
160
|
+
"[INFO] Default revision resolved to: '" +
|
|
152
161
|
self["revision"] + "'\n")
|
|
153
162
|
|
|
163
|
+
git_fetch_done = False
|
|
154
164
|
if do_git_fetch:
|
|
155
165
|
git_fetch_done = self._repo_fetch()
|
|
156
166
|
|
|
157
167
|
self._repo_fix_branch()
|
|
158
168
|
|
|
159
169
|
if git_fetch_done:
|
|
160
|
-
self.
|
|
170
|
+
self._git_apply_update_strategy()
|
|
161
171
|
|
|
162
|
-
|
|
163
|
-
self._exec_after(cwd=self.git_local_dir)
|
|
172
|
+
self._exec_after(cwd=self.git_local_dir)
|
|
164
173
|
except BatchFetchError as err:
|
|
165
174
|
self.set_error(True)
|
|
166
175
|
self.add_output(self.indent_spaces + "[ERROR] " + str(err) + "\n")
|
|
@@ -177,25 +186,26 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
177
186
|
|
|
178
187
|
return self.values
|
|
179
188
|
|
|
180
|
-
def _run_get_firstline(self, *args, **kwargs):
|
|
189
|
+
def _run_get_firstline(self, *args: Any, **kwargs: Any) -> str:
|
|
181
190
|
stdout, _ = self._run(*args, **kwargs)
|
|
182
191
|
try:
|
|
183
192
|
return stdout[0]
|
|
184
193
|
except IndexError:
|
|
185
194
|
return ""
|
|
186
195
|
|
|
187
|
-
def _run(self, cmd: Union[
|
|
196
|
+
def _run(self, cmd: Union[list[str], str],
|
|
188
197
|
cwd: Union[None, os.PathLike] = None,
|
|
189
198
|
env: Union[None, dict] = None,
|
|
190
|
-
**kwargs) ->
|
|
191
|
-
"""
|
|
199
|
+
**kwargs: Any) -> tuple[list[str], list[str]]:
|
|
200
|
+
"""Execute a command and return stdout and stderr.
|
|
201
|
+
|
|
192
202
|
Executes a command and returns stdout and stderr as separate lists of
|
|
193
203
|
strings.
|
|
194
204
|
|
|
195
205
|
:param cmd: Command to be executed. Can be a list or a string.
|
|
206
|
+
:param cwd: Current working directory.
|
|
207
|
+
:param env: Environment variables.
|
|
196
208
|
:param kwargs: Additional keyword arguments for Popen.
|
|
197
|
-
:cwd: Current working directory.
|
|
198
|
-
:env: Environment variables.
|
|
199
209
|
:return: Tuple containing two lists: stdout lines and stderr lines.
|
|
200
210
|
"""
|
|
201
211
|
if not cwd:
|
|
@@ -217,7 +227,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
217
227
|
return ""
|
|
218
228
|
return output
|
|
219
229
|
|
|
220
|
-
def _update_current_branch_name(self):
|
|
230
|
+
def _update_current_branch_name(self) -> None:
|
|
221
231
|
try:
|
|
222
232
|
# This returns the branch name
|
|
223
233
|
stdout, _ = self._run(["git", "symbolic-ref", "--short", "HEAD"])
|
|
@@ -239,7 +249,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
239
249
|
except GitRevisionDoesNotExist:
|
|
240
250
|
pass
|
|
241
251
|
|
|
242
|
-
def _repo_delete(self):
|
|
252
|
+
def _repo_delete(self) -> None:
|
|
243
253
|
if not self.git_local_dir.exists():
|
|
244
254
|
self.add_output(self.indent_spaces + "[INFO] Already deleted\n")
|
|
245
255
|
elif not self.git_local_dir.joinpath(".git").is_dir():
|
|
@@ -255,7 +265,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
255
265
|
+ f"[INFO] Deleted: '{self.git_local_dir}'")
|
|
256
266
|
self.set_changed(True)
|
|
257
267
|
|
|
258
|
-
def _repo_clone(self):
|
|
268
|
+
def _repo_clone(self) -> None:
|
|
259
269
|
git_clone_args = self["git_clone_args"]
|
|
260
270
|
# git_clone_args += ["--recurse-submodules"]
|
|
261
271
|
|
|
@@ -264,7 +274,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
264
274
|
self._run(cmd, cwd=".")
|
|
265
275
|
self.set_changed(True)
|
|
266
276
|
|
|
267
|
-
def _repo_fetch(self):
|
|
277
|
+
def _repo_fetch(self) -> bool:
|
|
268
278
|
# Merge
|
|
269
279
|
do_git_fetch = self["git_pull"]
|
|
270
280
|
do_git_fetch = False
|
|
@@ -304,26 +314,61 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
304
314
|
self._git_fetch_origin()
|
|
305
315
|
return True
|
|
306
316
|
|
|
307
|
-
def
|
|
308
|
-
|
|
317
|
+
def _is_working_tree_clean(self) -> bool:
|
|
318
|
+
"""Return True if the git working tree is clean."""
|
|
319
|
+
try:
|
|
320
|
+
stdout, _ = self._run(["git", "status", "--porcelain"])
|
|
321
|
+
return len(stdout) == 0
|
|
322
|
+
except subprocess.CalledProcessError:
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
def _git_apply_update_strategy(self) -> bool:
|
|
326
|
+
git_updated = False
|
|
309
327
|
|
|
310
|
-
# Merge
|
|
328
|
+
# Merge, Rebase, or Reset
|
|
311
329
|
real_branch = self._git_is_local_branch("HEAD")
|
|
312
330
|
if real_branch and self.current_branch:
|
|
313
331
|
# TODO: only merge when difference from upstream
|
|
314
332
|
commit_ref_head = self._git_ref(cwd=self.git_local_dir)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
strategy = self["git_update_strategy"]
|
|
336
|
+
except KeyError:
|
|
337
|
+
strategy = "merge"
|
|
338
|
+
|
|
339
|
+
if strategy in ("rebase", "reset"):
|
|
340
|
+
if not self._is_working_tree_clean():
|
|
341
|
+
raise BatchFetchError(
|
|
342
|
+
f"Working tree is not clean. Aborting {strategy} to "
|
|
343
|
+
"prevent data loss."
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
self.add_output(
|
|
347
|
+
self.indent_spaces
|
|
348
|
+
+ f"[INFO] Applying update strategy: {strategy} from "
|
|
349
|
+
f"origin/{self.current_branch}\n"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
if strategy == "rebase":
|
|
353
|
+
self._run(["git", "rebase"] +
|
|
354
|
+
[f"origin/{self.current_branch}"])
|
|
355
|
+
elif strategy == "reset":
|
|
356
|
+
self._run(["git", "reset", "--hard",
|
|
357
|
+
f"origin/{self.current_branch}"])
|
|
358
|
+
else:
|
|
359
|
+
self._run(["git", "merge"] + self["git_merge_args"] +
|
|
360
|
+
[f"origin/{self.current_branch}"])
|
|
361
|
+
|
|
362
|
+
git_ref_after_update = self._git_ref(cwd=self.git_local_dir)
|
|
363
|
+
if commit_ref_head != git_ref_after_update:
|
|
364
|
+
git_updated = True
|
|
320
365
|
self.set_changed(True)
|
|
321
366
|
self._run(["git", "log",
|
|
322
367
|
'--pretty=format:"%h %ad %s [%cn]"',
|
|
323
368
|
"--decorate", "--date=short",
|
|
324
|
-
f"{commit_ref_head}..{
|
|
369
|
+
f"{commit_ref_head}..{git_ref_after_update}"])
|
|
325
370
|
|
|
326
|
-
return
|
|
371
|
+
return git_updated
|
|
327
372
|
|
|
328
373
|
def _git_get_remote_url(self, remote_name: str = "origin") -> str:
|
|
329
374
|
origin_url = ""
|
|
@@ -348,7 +393,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
348
393
|
|
|
349
394
|
try:
|
|
350
395
|
stdout, _ = self._run(["git", "remote", "add", remote_name, url])
|
|
351
|
-
origin_url = stdout[0]
|
|
396
|
+
origin_url = stdout[0] if stdout else ""
|
|
352
397
|
except (subprocess.CalledProcessError, IndexError) as err:
|
|
353
398
|
raise GitRemoteError(
|
|
354
399
|
f"Failed to modify the Git remote url: {remote_name}") from err
|
|
@@ -356,7 +401,7 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
356
401
|
return origin_url
|
|
357
402
|
|
|
358
403
|
def _git_is_local_branch(self, branch: str) -> bool:
|
|
359
|
-
"Return True if it is a local branch that exists."
|
|
404
|
+
"""Return True if it is a local branch that exists."""
|
|
360
405
|
try:
|
|
361
406
|
stdout, _ = self._run(["git", "rev-parse", "--symbolic-full-name",
|
|
362
407
|
branch])
|
|
@@ -372,8 +417,8 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
372
417
|
|
|
373
418
|
return False
|
|
374
419
|
|
|
375
|
-
def _git_rev_parse_verify(self, revision: str) ->
|
|
376
|
-
stdout:
|
|
420
|
+
def _git_rev_parse_verify(self, revision: str) -> list[str]:
|
|
421
|
+
stdout: list[str] = []
|
|
377
422
|
error = False
|
|
378
423
|
try:
|
|
379
424
|
stdout, _ = self._run(["git", "rev-parse", "--verify", revision])
|
|
@@ -427,14 +472,14 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
427
472
|
|
|
428
473
|
return branch_changed
|
|
429
474
|
|
|
430
|
-
def _git_fetch_origin(self):
|
|
475
|
+
def _git_fetch_origin(self) -> None:
|
|
431
476
|
# Fetch
|
|
432
477
|
if not self._git_fetch_origin_done:
|
|
433
478
|
cmd = ["git", "fetch", "origin"]
|
|
434
479
|
self._run(cmd)
|
|
435
480
|
self._git_fetch_origin_done = True
|
|
436
481
|
|
|
437
|
-
def _repo_fix_remote_origin(self):
|
|
482
|
+
def _repo_fix_remote_origin(self) -> None:
|
|
438
483
|
correct_origin_url = self[self.main_key]
|
|
439
484
|
update_remote_origin = False
|
|
440
485
|
|
|
@@ -471,3 +516,30 @@ class BatchFetchGit(TaskBatchFetch):
|
|
|
471
516
|
# TODO: handle errors
|
|
472
517
|
except subprocess.CalledProcessError as err:
|
|
473
518
|
raise BatchFetchError(str(err)) from err
|
|
519
|
+
|
|
520
|
+
def _repo_fix_remotes(self) -> None:
|
|
521
|
+
"""Configure additional Git remotes defined in the task."""
|
|
522
|
+
remotes: dict[str, str] = self["remote"]
|
|
523
|
+
if not remotes:
|
|
524
|
+
return
|
|
525
|
+
|
|
526
|
+
for remote_name, correct_url in remotes.items():
|
|
527
|
+
update_remote = False
|
|
528
|
+
try:
|
|
529
|
+
current_url = self._git_get_remote_url(remote_name)
|
|
530
|
+
if current_url != correct_url:
|
|
531
|
+
update_remote = True
|
|
532
|
+
except GitRemoteError:
|
|
533
|
+
update_remote = True
|
|
534
|
+
|
|
535
|
+
if update_remote:
|
|
536
|
+
try:
|
|
537
|
+
self._git_set_remote_url(
|
|
538
|
+
url=correct_url, remote_name=remote_name)
|
|
539
|
+
self.add_output(self.indent_spaces
|
|
540
|
+
+ f"[INFO] Remote '{remote_name}' "
|
|
541
|
+
f"set to {correct_url}\n")
|
|
542
|
+
except GitRemoteError as err:
|
|
543
|
+
self.add_output(self.indent_spaces
|
|
544
|
+
+ "[ERROR] Failed to set "
|
|
545
|
+
f"remote '{remote_name}': {err}\n")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python
|
|
2
2
|
#
|
|
3
|
-
# Copyright (C) 2024-
|
|
3
|
+
# Copyright (C) 2024-2026 James Cherti
|
|
4
4
|
# URL: https://github.com/jamescherti/batchfetch
|
|
5
5
|
#
|
|
6
6
|
# This program is free software: you can redistribute it and/or modify it under
|
|
@@ -16,26 +16,26 @@
|
|
|
16
16
|
# You should have received a copy of the GNU General Public License along with
|
|
17
17
|
# this program. If not, see <https://www.gnu.org/licenses/>.
|
|
18
18
|
#
|
|
19
|
-
"Batchfetch helper functions."
|
|
19
|
+
"""Batchfetch helper functions."""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
20
22
|
|
|
21
23
|
import hashlib
|
|
22
24
|
import os
|
|
23
25
|
import shlex
|
|
24
26
|
from pathlib import Path
|
|
25
27
|
from subprocess import PIPE, CalledProcessError, Popen, list2cmdline
|
|
26
|
-
from typing import
|
|
28
|
+
from typing import Any, Union
|
|
27
29
|
|
|
28
30
|
|
|
29
|
-
def md5sum(filename: os.PathLike):
|
|
30
|
-
"""
|
|
31
|
-
Calculate and return the MD5 checksum of a file.
|
|
31
|
+
def md5sum(filename: os.PathLike) -> str:
|
|
32
|
+
"""Calculate and return the MD5 checksum of a file.
|
|
32
33
|
|
|
33
34
|
Args:
|
|
34
35
|
filename: Path to the file for which the MD5 checksum is calculated.
|
|
35
36
|
|
|
36
37
|
Returns:
|
|
37
38
|
str: The MD5 checksum of the file.
|
|
38
|
-
|
|
39
39
|
"""
|
|
40
40
|
md5 = hashlib.md5()
|
|
41
41
|
with open(filename, "rb") as file:
|
|
@@ -44,9 +44,10 @@ def md5sum(filename: os.PathLike):
|
|
|
44
44
|
return md5.hexdigest()
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
def run_simple(cmd: Union[
|
|
48
|
-
**kwargs) ->
|
|
49
|
-
"""
|
|
47
|
+
def run_simple(cmd: Union[list[str], str],
|
|
48
|
+
**kwargs: Any) -> tuple[list[str], list[str]]:
|
|
49
|
+
"""Execute a command and return stdout and stderr.
|
|
50
|
+
|
|
50
51
|
Executes a command and returns stdout and stderr as separate lists of
|
|
51
52
|
strings.
|
|
52
53
|
|
|
@@ -72,8 +73,9 @@ def run_simple(cmd: Union[List[str], str],
|
|
|
72
73
|
return (stdout_lines, stderr_lines)
|
|
73
74
|
|
|
74
75
|
|
|
75
|
-
def run_indent_str(cmd: Union[
|
|
76
|
-
"""
|
|
76
|
+
def run_indent_str(cmd: Union[list[str], str], **kwargs: Any) -> str:
|
|
77
|
+
"""Execute a command and return its stdout output as a single string.
|
|
78
|
+
|
|
77
79
|
Executes a command and returns its stdout output as a single string with
|
|
78
80
|
preserved line breaks.
|
|
79
81
|
|
|
@@ -87,9 +89,8 @@ def run_indent_str(cmd: Union[List[str], str], **kwargs) -> str:
|
|
|
87
89
|
return "\n".join(stdout) + "\n"
|
|
88
90
|
|
|
89
91
|
|
|
90
|
-
def indent_raw_output(raw_output:
|
|
91
|
-
"""
|
|
92
|
-
Indents each line of the given list of strings.
|
|
92
|
+
def indent_raw_output(raw_output: list[str], spaces: int = 4) -> list[str]:
|
|
93
|
+
"""Indent each line of the given list of strings.
|
|
93
94
|
|
|
94
95
|
:param raw_output: List of strings to indent.
|
|
95
96
|
:param spaces: Number of spaces to indent each line.
|
|
@@ -99,9 +100,10 @@ def indent_raw_output(raw_output: List[str], spaces: int = 4) -> List[str]:
|
|
|
99
100
|
return [indentation + line for line in raw_output]
|
|
100
101
|
|
|
101
102
|
|
|
102
|
-
def run_indent(cmd: Union[
|
|
103
|
-
**kwargs) ->
|
|
104
|
-
"""
|
|
103
|
+
def run_indent(cmd: Union[list[str], str], spaces: int = 4,
|
|
104
|
+
**kwargs: Any) -> tuple[list[str], list[str]]:
|
|
105
|
+
"""Execute a command and return its stdout and stderr output indented.
|
|
106
|
+
|
|
105
107
|
Executes a command and returns its stdout and stderr output, both indented.
|
|
106
108
|
|
|
107
109
|
:param cmd: Command to be executed, either as a list of strings or a single
|
|
@@ -119,15 +121,15 @@ def run_indent(cmd: Union[List[str], str], spaces: int = 4,
|
|
|
119
121
|
return (stdout, stderr)
|
|
120
122
|
|
|
121
123
|
|
|
122
|
-
def collect_parent_dirs(base_dir: Path,
|
|
124
|
+
def collect_parent_dirs(base_dir: Path, directory: Path) -> set[Path]:
|
|
123
125
|
"""Collect all parent directories of 'dir' until 'base_dir' is reached.
|
|
124
126
|
|
|
125
127
|
If 'dir' is not inside 'base_dir', return None.
|
|
126
128
|
"""
|
|
127
|
-
parents:
|
|
129
|
+
parents: set[Path] = set()
|
|
128
130
|
|
|
129
|
-
for dir_path, base_path in
|
|
130
|
-
(
|
|
131
|
+
for dir_path, base_path in ((directory.resolve(), base_dir.resolve()),
|
|
132
|
+
(directory.absolute(), base_dir.absolute())):
|
|
131
133
|
try:
|
|
132
134
|
# Check if dir is inside base_dir
|
|
133
135
|
dir_path.relative_to(base_path)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: batchfetch
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.7
|
|
4
4
|
Summary: Efficiently clone and pull multiple Git repositories.
|
|
5
5
|
Home-page: https://github.com/jamescherti/batchfetch
|
|
6
6
|
Author: James Cherti
|
|
@@ -117,6 +117,7 @@ options:
|
|
|
117
117
|
```
|
|
118
118
|
|
|
119
119
|
## Features
|
|
120
|
+
|
|
120
121
|
- Git Clone and Fetch/Merge: Clones the repositories and their submodules, ensuring that all the repositories are always up-to-date by fetching and merging changes.
|
|
121
122
|
- Parallel Operations: Utilizes threads to simultaneously Git clone or pull multiple repositories, dramatically reducing wait times.
|
|
122
123
|
- User-Friendly Interface: Provides simple and straightforward command-line options that make it easy to get started and effectively manage your repositories.
|
|
@@ -135,7 +136,7 @@ When *batchfetch* encounters an untracked file, it displays an error message to
|
|
|
135
136
|
|
|
136
137
|
Here is an example of a *batchfetch.yaml* file that enables *batchfetch* to accept a list of untracked files:
|
|
137
138
|
|
|
138
|
-
```
|
|
139
|
+
```yaml
|
|
139
140
|
options:
|
|
140
141
|
ignore_untracked:
|
|
141
142
|
- ./test
|
|
@@ -161,7 +162,8 @@ Batchfetch is fast, not only because it runs Git commands in parallel, but also
|
|
|
161
162
|
When the user has specifies a revision (branch or commit reference), Batchfetch only performs a `git fetch` if that revision does not exist locally. If the revision is already up to date, it simply proceeds to the next repository in the queue.
|
|
162
163
|
|
|
163
164
|
That's why it is highly recommended to always specify the revision to speed up Batchfetch, if speed is important to you. Here is an example of a `batchfetch.yaml` file where the branch (`1.1.0`) or commit reference (`b9c6d9b6134b4981760893254f804a371ffbc899`) is specified:
|
|
164
|
-
|
|
165
|
+
|
|
166
|
+
```yaml
|
|
165
167
|
tasks:
|
|
166
168
|
- git: https://github.com/jamescherti/outline-indent.el
|
|
167
169
|
revision: "1.1.0"
|
|
@@ -171,12 +173,59 @@ tasks:
|
|
|
171
173
|
revision: b9c6d9b6134b4981760893254f804a371ffbc899
|
|
172
174
|
```
|
|
173
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
|
+
### How to customize Git clone, merge, and update strategies?
|
|
191
|
+
|
|
192
|
+
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.
|
|
193
|
+
|
|
194
|
+
* `git_clone_args`: A list of extra arguments to pass to `git clone`.
|
|
195
|
+
* `git_merge_args`: A list of extra arguments to pass to `git merge`.
|
|
196
|
+
* `git_update_strategy`: Specifies how updates should be applied. Valid options are `"merge"` (default), `"rebase"`, or `"reset"`.
|
|
197
|
+
* `git_pull`: A boolean indicating whether to run Git pull/fetch operations. Set to `false` to disable updating entirely (default: `true`).
|
|
198
|
+
|
|
199
|
+
Here is an example demonstrating both global and local configurations:
|
|
200
|
+
|
|
201
|
+
```yaml
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
options:
|
|
205
|
+
# Apply these defaults to all tasks
|
|
206
|
+
git_update_strategy: reset
|
|
207
|
+
git_clone_args:
|
|
208
|
+
- --recurse-submodules
|
|
209
|
+
|
|
210
|
+
tasks:
|
|
211
|
+
- git: https://github.com/jamescherti/compile-angel.el
|
|
212
|
+
git_pull: false # Do not attempt to update this repo
|
|
213
|
+
|
|
214
|
+
- git: https://github.com/jamescherti/outline-indent.el
|
|
215
|
+
# Override global strategy specifically for this task
|
|
216
|
+
git_update_strategy: merge
|
|
217
|
+
git_merge_args:
|
|
218
|
+
- --no-ff
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
|
|
174
222
|
### How to execute a command before and after a task?
|
|
175
223
|
|
|
176
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.
|
|
177
225
|
|
|
178
226
|
Here is an example:
|
|
179
|
-
|
|
227
|
+
|
|
228
|
+
```yaml
|
|
180
229
|
---
|
|
181
230
|
tasks:
|
|
182
231
|
- git: https://github.com/jamescherti/easysession.el
|
|
@@ -192,6 +241,7 @@ To configure `batchfetch` to handle a specific path, you can define your tasks i
|
|
|
192
241
|
#### Example `batchfetch.yml` file:
|
|
193
242
|
|
|
194
243
|
In the following example, the `easysession` task clones two Git repositories:
|
|
244
|
+
|
|
195
245
|
```yaml
|
|
196
246
|
---
|
|
197
247
|
tasks:
|
|
@@ -227,7 +277,7 @@ batchfetch
|
|
|
227
277
|
|
|
228
278
|
## License
|
|
229
279
|
|
|
230
|
-
Copyright (C) 2024-
|
|
280
|
+
Copyright (C) 2024-2026 [James Cherti](https://www.jamescherti.com)
|
|
231
281
|
|
|
232
282
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
233
283
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python
|
|
2
2
|
#
|
|
3
|
-
# Copyright (C) 2024-
|
|
3
|
+
# Copyright (C) 2024-2026 James Cherti
|
|
4
4
|
# URL: https://github.com/jamescherti/batchfetch
|
|
5
5
|
#
|
|
6
6
|
# This program is free software: you can redistribute it and/or modify it under
|
|
@@ -22,7 +22,7 @@ from setuptools import find_packages, setup
|
|
|
22
22
|
|
|
23
23
|
setup(
|
|
24
24
|
name="batchfetch",
|
|
25
|
-
version="1.3.
|
|
25
|
+
version="1.3.7",
|
|
26
26
|
packages=find_packages(),
|
|
27
27
|
description="Efficiently clone and pull multiple Git repositories.",
|
|
28
28
|
license="GPLv3",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|