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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batchfetch
3
- Version: 1.3.6
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
- ``` yaml
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
- ``` yaml
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
- ``` yaml
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-2025 [James Cherti](https://www.jamescherti.com)
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
- ``` yaml
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
- ``` yaml
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
- ``` yaml
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-2025 [James Cherti](https://www.jamescherti.com)
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-2025 James Cherti
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
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python
2
2
  #
3
- # Copyright (C) 2024-2025 James Cherti
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, Dict, List
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: Dict[str, Any], options: Dict[str, Any]):
41
- self.global_options_schema: Dict[Any, Any] = {}
42
- self.global_options_values: Dict[str, Any] = options
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: Dict[Any, Any] = {}
45
- self.task_default_values: Dict[str, Any] = {}
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: Dict[str, Any] = {}
51
- self.options: Dict[str, Any] = {}
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: Dict[str, Any], options: Dict[str, Any]):
98
- new_options: Dict[str, Any] = {"exec_before": [],
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: Dict[Any, Any] = {
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: Dict[Any, Any] = {
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: Dict[str, Any] = {
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-2025 James Cherti
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, Dict, List, Set, Union
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, TaskBatchFetch
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: List[str], cwd: os.PathLike):
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: Dict[Path, Set[str]] = {}
53
- self.ignore_untracked: Set[Path] = set()
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: Set[str] = set()
60
+ self.dirs_relative_to_batchfetch: set[str] = set()
57
61
 
58
62
  # Plugin
59
- self.batchfetch_schemas: Dict[Any, Any] = {}
60
- self.batchfetch_classes: Dict[str, TaskBatchFetch] = {}
61
- self.cfg_schema: Dict[Any, Any] = {}
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
- (os.sep if path.is_dir() else "") +
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
- jobs = os.environ["BATCHFETCH_JOBS"]
339
+ jobs_env = os.environ["BATCHFETCH_JOBS"]
334
340
  except KeyError:
335
341
  jobs = 5
336
342
  else:
337
- if jobs:
338
- jobs = int(jobs)
343
+ jobs = int(jobs_env)
339
344
 
340
345
  # Check untracked
341
346
  try:
342
- check_untracked = os.environ["BATCHFETCH_CHECK_UNTRACKED"]
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(check_untracked)
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-2025 James Cherti
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 List, Tuple, Union
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
- if self["revision"] else "") + "\n")
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] Update revision to: '" +
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._git_merge()
170
+ self._git_apply_update_strategy()
161
171
 
162
- if self.get_changed():
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[List[str], str],
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) -> Tuple[List[str], List[str]]:
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 _git_merge(self):
308
- git_merge = False
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
- self._run(["git", "merge", "--ff-only",
316
- f"origin/{self.current_branch}"])
317
- git_ref_after_merge = self._git_ref(cwd=self.git_local_dir)
318
- if commit_ref_head != git_ref_after_merge:
319
- git_merge = True
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}..{git_ref_after_merge}"])
369
+ f"{commit_ref_head}..{git_ref_after_update}"])
325
370
 
326
- return git_merge
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) -> List[str]:
376
- stdout: List[str] = []
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-2025 James Cherti
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 List, Set, Tuple, Union
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[List[str], str],
48
- **kwargs) -> Tuple[List[str], List[str]]:
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[List[str], str], **kwargs) -> str:
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: List[str], spaces: int = 4) -> List[str]:
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[List[str], str], spaces: int = 4,
103
- **kwargs) -> Tuple[List[str], List[str]]:
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, dir: Path) -> Set[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: Set[Path] = set()
129
+ parents: set[Path] = set()
128
130
 
129
- for dir_path, base_path in [(dir.resolve(), base_dir.resolve()),
130
- (dir.absolute(), base_dir.absolute())]:
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.6
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
- ``` yaml
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
- ``` yaml
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
- ``` yaml
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-2025 [James Cherti](https://www.jamescherti.com)
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-2025 James Cherti
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.6",
25
+ version="1.3.7",
26
26
  packages=find_packages(),
27
27
  description="Efficiently clone and pull multiple Git repositories.",
28
28
  license="GPLv3",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python
2
2
  #
3
- # Copyright (C) 2024-2025 James Cherti
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
File without changes
File without changes