batchfetch 1.3.7__tar.gz → 1.3.9__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.
@@ -0,0 +1 @@
1
+ github: jamescherti
@@ -0,0 +1,138 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
98
+ __pypackages__/
99
+
100
+ # Celery stuff
101
+ celerybeat-schedule
102
+ celerybeat.pid
103
+
104
+ # SageMath parsed files
105
+ *.sage.py
106
+
107
+ # Environments
108
+ .env
109
+ .venv
110
+ env/
111
+ venv/
112
+ ENV/
113
+ env.bak/
114
+ venv.bak/
115
+
116
+ # Spyder project settings
117
+ .spyderproject
118
+ .spyproject
119
+
120
+ # Rope project settings
121
+ .ropeproject
122
+
123
+ # mkdocs documentation
124
+ /site
125
+
126
+ # mypy
127
+ .mypy_cache/
128
+ .dmypy.json
129
+ dmypy.json
130
+
131
+ # Pyre type checker
132
+ .pyre/
133
+
134
+ # pytype static type analyzer
135
+ .pytype/
136
+
137
+ # Cython debug symbols
138
+ cython_debug/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batchfetch
3
- Version: 1.3.7
3
+ Version: 1.3.9
4
4
  Summary: Efficiently clone and pull multiple Git repositories.
5
5
  Home-page: https://github.com/jamescherti/batchfetch
6
6
  Author: James Cherti
@@ -80,7 +80,7 @@ tasks:
80
80
  delete: true
81
81
  ```
82
82
 
83
- Execute the `batchfetch` command from the same directory as `batchfetch.yml` to make it clone or update the local copies of the repositories above.
83
+ Execute the `batchfetch` command from the same directory as `batchfetch.yaml` to make it clone or update the local copies of the repositories above.
84
84
 
85
85
  ## Command-line options
86
86
 
@@ -94,7 +94,7 @@ Efficiently clone/pull multiple Git repositories in parallel.
94
94
  positional arguments:
95
95
  target This is a target path that batchfetch is supposed to
96
96
  handle. When no target is specified, execute the tasks
97
- of all target paths defined in the batchfetch.yml list
97
+ of all target paths defined in the batchfetch.yaml list
98
98
  of tasks.
99
99
 
100
100
  options:
@@ -119,7 +119,7 @@ options:
119
119
  ## Features
120
120
 
121
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.
122
- - Parallel Operations: Utilizes threads to simultaneously Git clone or pull multiple repositories, dramatically reducing wait times.
122
+ - Parallel Operations: Uses threads to simultaneously Git clone or pull multiple repositories, reducing wait times.
123
123
  - User-Friendly Interface: Provides simple and straightforward command-line options that make it easy to get started and effectively manage your repositories.
124
124
  - Custom Configuration: Allows the use of a YAML configuration file to specify and manage the repositories you interact with, enabling repeatable setups and consistent environments.
125
125
  - Detect files that should not be present in directories managed by batchfetch, known as untracked files.
@@ -173,20 +173,6 @@ tasks:
173
173
  revision: b9c6d9b6134b4981760893254f804a371ffbc899
174
174
  ```
175
175
 
176
- ### How to configure additional Git remotes?
177
-
178
- You can define additional remotes for a Git repository using the `remote` option. Pass a list of key-value pairs where the key is the remote name and the value is the corresponding URL. Batchfetch will automatically ensure these remotes are present and point to the correct URLs.
179
-
180
- ```yaml
181
- ---
182
- tasks:
183
- - git: https://github.com/jamescherti/easysession.el
184
- path: emacs/easysession
185
- remote:
186
- upstream: https://github.com/name/repo
187
- upstream2: https://github.com/name/repo2
188
- ```
189
-
190
176
  ### How to customize Git clone, merge, and update strategies?
191
177
 
192
178
  Batchfetch allows you to define advanced options for interacting with Git repositories. These parameters can be declared globally under the `options` block to apply to all tasks, or individually within a specific task to override the global defaults.
@@ -219,6 +205,20 @@ tasks:
219
205
 
220
206
  ```
221
207
 
208
+ ### How to configure additional Git remotes?
209
+
210
+ You can define additional remotes for a Git repository using the `remote` option. Pass a list of key-value pairs where the key is the remote name and the value is the corresponding URL. Batchfetch will automatically ensure these remotes are present and point to the correct URLs.
211
+
212
+ ```yaml
213
+ ---
214
+ tasks:
215
+ - git: https://github.com/jamescherti/easysession.el
216
+ path: emacs/easysession
217
+ remote:
218
+ upstream: https://github.com/name/repo
219
+ upstream2: https://github.com/name/repo2
220
+ ```
221
+
222
222
  ### How to execute a command before and after a task?
223
223
 
224
224
  To execute a command both before and after a specific task, you can define the `exec_before` and `exec_after` directives within the task configuration. These directives specify commands to be executed at the respective stages of the task lifecycle.
@@ -236,9 +236,9 @@ tasks:
236
236
 
237
237
  ### How to make batchfetch handle only one path?
238
238
 
239
- To configure `batchfetch` to handle a specific path, you can define your tasks in a `batchfetch.yml` file and pass the desired path as an argument to the `batchfetch` command.
239
+ To configure `batchfetch` to handle a specific path, you can define your tasks in a `batchfetch.yaml` file and pass the desired path as an argument to the `batchfetch` command.
240
240
 
241
- #### Example `batchfetch.yml` file:
241
+ #### Example `batchfetch.yaml` file:
242
242
 
243
243
  In the following example, the `easysession` task clones two Git repositories:
244
244
 
@@ -258,7 +258,7 @@ To make `batchfetch` clone only `easysession`, pass its path as an argument:
258
258
  batchfetch easysession
259
259
  ```
260
260
 
261
- This will execute only the task corresponding to the `easysession` path, skipping all others in the `batchfetch.yml` file.
261
+ This will execute only the task corresponding to the `easysession` path, skipping all others in the `batchfetch.yaml` file.
262
262
 
263
263
  ### How can I configure batchfetch to load a file other than batchfetch.yaml?
264
264
 
@@ -46,7 +46,7 @@ tasks:
46
46
  delete: true
47
47
  ```
48
48
 
49
- Execute the `batchfetch` command from the same directory as `batchfetch.yml` to make it clone or update the local copies of the repositories above.
49
+ Execute the `batchfetch` command from the same directory as `batchfetch.yaml` to make it clone or update the local copies of the repositories above.
50
50
 
51
51
  ## Command-line options
52
52
 
@@ -60,7 +60,7 @@ Efficiently clone/pull multiple Git repositories in parallel.
60
60
  positional arguments:
61
61
  target This is a target path that batchfetch is supposed to
62
62
  handle. When no target is specified, execute the tasks
63
- of all target paths defined in the batchfetch.yml list
63
+ of all target paths defined in the batchfetch.yaml list
64
64
  of tasks.
65
65
 
66
66
  options:
@@ -85,7 +85,7 @@ options:
85
85
  ## Features
86
86
 
87
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.
88
- - Parallel Operations: Utilizes threads to simultaneously Git clone or pull multiple repositories, dramatically reducing wait times.
88
+ - Parallel Operations: Uses threads to simultaneously Git clone or pull multiple repositories, reducing wait times.
89
89
  - User-Friendly Interface: Provides simple and straightforward command-line options that make it easy to get started and effectively manage your repositories.
90
90
  - Custom Configuration: Allows the use of a YAML configuration file to specify and manage the repositories you interact with, enabling repeatable setups and consistent environments.
91
91
  - Detect files that should not be present in directories managed by batchfetch, known as untracked files.
@@ -139,20 +139,6 @@ tasks:
139
139
  revision: b9c6d9b6134b4981760893254f804a371ffbc899
140
140
  ```
141
141
 
142
- ### How to configure additional Git remotes?
143
-
144
- You can define additional remotes for a Git repository using the `remote` option. Pass a list of key-value pairs where the key is the remote name and the value is the corresponding URL. Batchfetch will automatically ensure these remotes are present and point to the correct URLs.
145
-
146
- ```yaml
147
- ---
148
- tasks:
149
- - git: https://github.com/jamescherti/easysession.el
150
- path: emacs/easysession
151
- remote:
152
- upstream: https://github.com/name/repo
153
- upstream2: https://github.com/name/repo2
154
- ```
155
-
156
142
  ### How to customize Git clone, merge, and update strategies?
157
143
 
158
144
  Batchfetch allows you to define advanced options for interacting with Git repositories. These parameters can be declared globally under the `options` block to apply to all tasks, or individually within a specific task to override the global defaults.
@@ -185,6 +171,20 @@ tasks:
185
171
 
186
172
  ```
187
173
 
174
+ ### How to configure additional Git remotes?
175
+
176
+ You can define additional remotes for a Git repository using the `remote` option. Pass a list of key-value pairs where the key is the remote name and the value is the corresponding URL. Batchfetch will automatically ensure these remotes are present and point to the correct URLs.
177
+
178
+ ```yaml
179
+ ---
180
+ tasks:
181
+ - git: https://github.com/jamescherti/easysession.el
182
+ path: emacs/easysession
183
+ remote:
184
+ upstream: https://github.com/name/repo
185
+ upstream2: https://github.com/name/repo2
186
+ ```
187
+
188
188
  ### How to execute a command before and after a task?
189
189
 
190
190
  To execute a command both before and after a specific task, you can define the `exec_before` and `exec_after` directives within the task configuration. These directives specify commands to be executed at the respective stages of the task lifecycle.
@@ -202,9 +202,9 @@ tasks:
202
202
 
203
203
  ### How to make batchfetch handle only one path?
204
204
 
205
- To configure `batchfetch` to handle a specific path, you can define your tasks in a `batchfetch.yml` file and pass the desired path as an argument to the `batchfetch` command.
205
+ To configure `batchfetch` to handle a specific path, you can define your tasks in a `batchfetch.yaml` file and pass the desired path as an argument to the `batchfetch` command.
206
206
 
207
- #### Example `batchfetch.yml` file:
207
+ #### Example `batchfetch.yaml` file:
208
208
 
209
209
  In the following example, the `easysession` task clones two Git repositories:
210
210
 
@@ -224,7 +224,7 @@ To make `batchfetch` clone only `easysession`, pass its path as an argument:
224
224
  batchfetch easysession
225
225
  ```
226
226
 
227
- This will execute only the task corresponding to the `easysession` path, skipping all others in the `batchfetch.yml` file.
227
+ This will execute only the task corresponding to the `easysession` path, skipping all others in the `batchfetch.yaml` file.
228
228
 
229
229
  ### How can I configure batchfetch to load a file other than batchfetch.yaml?
230
230
 
@@ -31,7 +31,7 @@ from .helpers import run_indent_str
31
31
 
32
32
 
33
33
  class BatchFetchError(Exception):
34
- """Exception raised by Downloader()."""
34
+ """Exception raised by batchfetch functions."""
35
35
 
36
36
 
37
37
  class DataAlreadyInitialized(Exception):
@@ -39,7 +39,10 @@ class DataAlreadyInitialized(Exception):
39
39
 
40
40
 
41
41
  class TaskBase:
42
+ """Base class representing a general task to be executed."""
43
+
42
44
  def __init__(self, data: dict[str, Any], options: dict[str, Any]) -> None:
45
+ """Initialize the task with specific data and options."""
43
46
  self.global_options_schema: dict[Any, Any] = {}
44
47
  self.global_options_values: dict[str, Any] = options
45
48
 
@@ -54,6 +57,7 @@ class TaskBase:
54
57
  self._values_initialized = False
55
58
 
56
59
  def _initialize_data(self) -> None:
60
+ """Initialize the options and data values for the task."""
57
61
  if self._values_initialized:
58
62
  raise DataAlreadyInitialized
59
63
 
@@ -79,9 +83,11 @@ class TaskBase:
79
83
  }
80
84
 
81
85
  def validate_schema(self) -> None:
86
+ """Validate the schema of the task data and options."""
82
87
  self._initialize_data()
83
88
 
84
89
  def __getitem__(self, key: str) -> Any:
90
+ """Retrieve an item from the task values or options."""
85
91
  self._initialize_data()
86
92
 
87
93
  if key in self.values:
@@ -90,13 +96,14 @@ class TaskBase:
90
96
  if key in self.options:
91
97
  return self.options[key]
92
98
 
93
- raise KeyError(f"The item '{key}' was not found in '{self.values}")
99
+ raise KeyError(f"The item '{key}' was not found in '{self.values}'")
94
100
 
95
101
 
96
102
  class TaskBatchFetch(TaskBase):
97
103
  """Plugin downloader base class."""
98
104
 
99
105
  def __init__(self, data: dict[str, Any], options: dict[str, Any]) -> None:
106
+ """Initialize the batchfetch task plugin."""
100
107
  new_options: dict[str, Any] = {"exec_before": [],
101
108
  "exec_after": [],
102
109
  "ignore_untracked": []}
@@ -132,6 +139,7 @@ class TaskBatchFetch(TaskBase):
132
139
  }
133
140
 
134
141
  def _initialize_data(self) -> None:
142
+ """Safely initialize data for the batchfetch task."""
135
143
  try:
136
144
  super()._initialize_data()
137
145
  except DataAlreadyInitialized:
@@ -141,6 +149,7 @@ class TaskBatchFetch(TaskBase):
141
149
  self._values_initialized = True
142
150
 
143
151
  def _local_task_exec(self, *args: Any, **kwargs: Any) -> None:
152
+ """Execute a local task command and capture its output."""
144
153
  stdout = run_indent_str(env=self.env, spaces=self.indent,
145
154
  *args, **kwargs)
146
155
  if not stdout.endswith("\n"):
@@ -148,6 +157,7 @@ class TaskBatchFetch(TaskBase):
148
157
  self.add_output(stdout)
149
158
 
150
159
  def _exec_before(self, cwd: os.PathLike = Path(".")) -> None:
160
+ """Execute pre-task commands defined in options and task items."""
151
161
  self._initialize_data()
152
162
  if self["delete"]:
153
163
  return
@@ -165,6 +175,7 @@ class TaskBatchFetch(TaskBase):
165
175
  self._local_task_exec(cmd, cwd=str(cwd))
166
176
 
167
177
  def _exec_after(self, cwd: os.PathLike = Path(".")) -> None:
178
+ """Execute post-task commands if the task was changed successfully."""
168
179
  self._initialize_data()
169
180
  if self["delete"] or self.is_error() or not self.is_changed():
170
181
  return
@@ -182,32 +193,39 @@ class TaskBatchFetch(TaskBase):
182
193
  self._local_task_exec(cmd, cwd=str(cwd))
183
194
 
184
195
  def is_changed(self) -> bool:
196
+ """Return True if the task execution resulted in changes."""
185
197
  self._initialize_data()
186
198
  return bool(self.values["result"]["changed"])
187
199
 
188
200
  def set_changed(self, changed: bool) -> None:
201
+ """Set the changed status of the task."""
189
202
  self._initialize_data()
190
203
  self.values["result"]["changed"] = changed
191
204
 
192
205
  def get_changed(self) -> bool:
206
+ """Return the changed status of the task without initializing data."""
193
207
  try:
194
208
  return bool(self.values["result"]["changed"])
195
209
  except KeyError:
196
210
  return False
197
211
 
198
212
  def is_error(self) -> bool:
213
+ """Return True if the task encountered an error."""
199
214
  self._initialize_data()
200
215
  return bool(self.values["result"]["error"])
201
216
 
202
217
  def set_error(self, error: bool) -> None:
218
+ """Set the error status of the task."""
203
219
  self._initialize_data()
204
220
  self.values["result"]["error"] = error
205
221
 
206
222
  def set_output(self, output: str) -> None:
223
+ """Set the output string of the task."""
207
224
  self._initialize_data()
208
225
  self.values["result"]["output"] = output
209
226
 
210
227
  def add_output(self, output: str) -> None:
228
+ """Append a string to the output of the task."""
211
229
  self._initialize_data()
212
230
  self.values["result"]["output"] += output
213
231
 
@@ -217,5 +235,6 @@ class TaskBatchFetch(TaskBase):
217
235
  return str(self.values["result"]["output"])
218
236
 
219
237
  def update(self) -> dict[str, Any]:
238
+ """Update the task data and return the current values."""
220
239
  self._initialize_data()
221
240
  return self.values
@@ -47,11 +47,12 @@ class BatchFetchCli:
47
47
  # pylint: disable=too-many-positional-arguments
48
48
  def __init__(self, max_workers: int, verbose: bool, check_untracked: bool,
49
49
  targets: list[str], cwd: os.PathLike) -> None:
50
+ """Initialize the batchfetch command-line interface."""
50
51
  os.chdir(cwd)
51
52
  self._cwd = cwd
52
53
  self._logger = logging.getLogger(self.__class__.__name__)
53
54
  self.targets = {Path(item).absolute() for item in targets}
54
- self.cfg: dict = {}
55
+ self.cfg: dict[str, Any] = {}
55
56
  self.check_untracked = check_untracked
56
57
  self.tracked_paths: dict[Path, set[str]] = {}
57
58
  self.ignore_untracked: set[Path] = set()
@@ -66,6 +67,7 @@ class BatchFetchCli:
66
67
  self._plugin_add("git", BatchFetchGit)
67
68
 
68
69
  def _plugin_add(self, keyword: str, batchfetch_class: Any) -> None:
70
+ """Register a plugin class and its schema."""
69
71
  batchfetch_instance = batchfetch_class(data={},
70
72
  options={})
71
73
  batchfetch_instance.validate_schema()
@@ -81,6 +83,7 @@ class BatchFetchCli:
81
83
  }
82
84
 
83
85
  def _plugin_get(self, raw_data: dict[str, Any]) -> str:
86
+ """Retrieve the correct plugin keyword based on raw data."""
84
87
  keyword_found = None
85
88
  for keyword in self.batchfetch_classes:
86
89
  if keyword in raw_data:
@@ -99,10 +102,12 @@ class BatchFetchCli:
99
102
  return keyword_found
100
103
 
101
104
  def load(self, path: Path) -> None:
105
+ """Load and parse the batchfetch YAML configuration file."""
102
106
  # pylint: disable=too-many-try-statements
103
107
  try:
104
108
  with open(path, "r", encoding="utf-8") as fhandler:
105
- yaml_dict = yaml.load(fhandler, Loader=yaml.FullLoader)
109
+ yaml_dict: Any = \
110
+ yaml.load(fhandler, Loader=yaml.FullLoader)
106
111
  if not isinstance(yaml_dict, dict):
107
112
  print(f"Error: Invalid format: {yaml_dict}.",
108
113
  file=sys.stderr)
@@ -142,6 +147,7 @@ class BatchFetchCli:
142
147
  raise BatchFetchError(str(err)) from err
143
148
 
144
149
  def _loads(self, data: dict[str, Any]) -> None:
150
+ """Validate and load the configuration data dictionary."""
145
151
  schema = Schema(self.cfg_schema)
146
152
  try:
147
153
  schema.validate(data)
@@ -160,6 +166,7 @@ class BatchFetchCli:
160
166
  self._loads_tasks(data)
161
167
 
162
168
  def _loads_tasks(self, data: dict[str, Any]) -> None:
169
+ """Load and validate tasks from the configuration data."""
163
170
  if "tasks" not in data:
164
171
  return
165
172
 
@@ -191,6 +198,7 @@ class BatchFetchCli:
191
198
  # pylint: disable=too-many-branches
192
199
  # pylint: disable=too-many-statements
193
200
  def run_tasks(self) -> bool:
201
+ """Execute all configured tasks in parallel using a thread pool."""
194
202
  failed = []
195
203
  error = False
196
204
  threads = []
@@ -279,7 +287,7 @@ class BatchFetchCli:
279
287
 
280
288
  return False
281
289
  else:
282
- if self.check_untracked:
290
+ if self.check_untracked and not self.targets:
283
291
  self._find_untracked_paths()
284
292
 
285
293
  if num_success == 0:
@@ -358,7 +366,7 @@ def parse_args() -> argparse.Namespace:
358
366
  "targets", metavar="target", type=str, nargs="*",
359
367
  help=("This is a target path that batchfetch is supposed to handle. "
360
368
  "When no target is specified, execute the tasks of all target "
361
- "paths defined in the batchfetch.yml list of tasks."),
369
+ "paths defined in the batchfetch.yaml list of tasks."),
362
370
  )
363
371
 
364
372
  parser.add_argument(
@@ -421,7 +429,7 @@ def parse_args() -> argparse.Namespace:
421
429
 
422
430
 
423
431
  def command_line_interface() -> None:
424
- """Command line interface."""
432
+ """Entry point for the command line interface."""
425
433
  # pylint: disable=too-many-try-statements
426
434
  try:
427
435
  errno = 0
@@ -459,7 +467,7 @@ def command_line_interface() -> None:
459
467
  except KeyboardInterrupt:
460
468
  print("Interrupted.", file=sys.stderr)
461
469
  errno = 1
462
- except (yaml.parser.MarkedYAMLError, BatchFetchError) as err:
470
+ except (yaml.error.MarkedYAMLError, BatchFetchError) as err:
463
471
  print(f"Error: {err}.", file=sys.stderr)
464
472
  errno = 1
465
473
 
@@ -20,6 +20,7 @@
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
+ import logging
23
24
  import os
24
25
  import shutil
25
26
  import subprocess
@@ -45,9 +46,10 @@ class BatchFetchGit(TaskBatchFetch):
45
46
  """Clone or update a Git repository."""
46
47
 
47
48
  def __init__(self, *args: Any, **kwargs: Any) -> None:
49
+ """Initialize the Git batchfetch task."""
48
50
  self._git_fetch_origin_done = False
49
51
 
50
- self.branch_commit_ref = None
52
+ self.branch_commit_ref: str | None = None
51
53
  self.is_branch = False
52
54
 
53
55
  super().__init__(*args, **kwargs)
@@ -90,10 +92,11 @@ class BatchFetchGit(TaskBatchFetch):
90
92
  })
91
93
 
92
94
  self.git_local_dir = Path(self["path"])
93
- self.current_branch = None
94
- self.current_commit_ref = None
95
+ self.current_branch: str | None = None
96
+ self.current_commit_ref: str | None = None
95
97
 
96
98
  def _initialize_data(self) -> None:
99
+ """Initialize and parse the repository path data."""
97
100
  super()._initialize_data()
98
101
 
99
102
  self.values[self.main_key] = \
@@ -149,8 +152,15 @@ class BatchFetchGit(TaskBatchFetch):
149
152
  self._exec_before(cwd=self.git_local_dir)
150
153
 
151
154
  if not self["revision"]:
152
- self.values["revision"] = self._run_get_firstline(
153
- "git symbolic-ref --short HEAD")
155
+ # Attempt to read the default branch from local origin/HEAD
156
+ sym_ref = self._run_get_firstline(
157
+ ["git", "symbolic-ref", "--short",
158
+ "refs/remotes/origin/HEAD"]
159
+ ).strip()
160
+ if sym_ref.startswith("origin/"):
161
+ self.values["revision"] = \
162
+ sym_ref.split("origin/", 1)[1]
163
+
154
164
  if not self["revision"]:
155
165
  raise BatchFetchError(
156
166
  "Unable to determine the default origin branch"
@@ -187,6 +197,7 @@ class BatchFetchGit(TaskBatchFetch):
187
197
  return self.values
188
198
 
189
199
  def _run_get_firstline(self, *args: Any, **kwargs: Any) -> str:
200
+ """Run a command and return the first line of standard output."""
190
201
  stdout, _ = self._run(*args, **kwargs)
191
202
  try:
192
203
  return stdout[0]
@@ -194,8 +205,8 @@ class BatchFetchGit(TaskBatchFetch):
194
205
  return ""
195
206
 
196
207
  def _run(self, cmd: Union[list[str], str],
197
- cwd: Union[None, os.PathLike] = None,
198
- env: Union[None, dict] = None,
208
+ cwd: Union[None, os.PathLike, str] = None,
209
+ env: Union[None, dict[str, str]] = None,
199
210
  **kwargs: Any) -> tuple[list[str], list[str]]:
200
211
  """Execute a command and return stdout and stderr.
201
212
 
@@ -214,6 +225,33 @@ class BatchFetchGit(TaskBatchFetch):
214
225
  env = self.env
215
226
  return run_simple(cmd=cmd, env=env, cwd=cwd, **kwargs)
216
227
 
228
+ def _get_upstream_branch(self) -> str:
229
+ """Return the upstream tracking branch for the current branch."""
230
+ if not self.current_branch:
231
+ raise BatchFetchError(
232
+ "Not currently on any branch. Cannot determine upstream."
233
+ )
234
+
235
+ try:
236
+ stdout, _ = self._run(
237
+ ["git", "rev-parse", "--abbrev-ref",
238
+ f"{self.current_branch}@{{upstream}}"]
239
+ )
240
+ # TODO: Don't repeat strip 2 times
241
+ if stdout and stdout[0].strip():
242
+ return stdout[0].strip()
243
+ except subprocess.CalledProcessError as err:
244
+ raise BatchFetchError(
245
+ "No upstream tracking branch found "
246
+ f"for '{self.current_branch}'. "
247
+ "Please configure an upstream branch before updating."
248
+ ) from err
249
+
250
+ raise BatchFetchError(
251
+ "Failed to parse upstream tracking "
252
+ f"branch for '{self.current_branch}'."
253
+ )
254
+
217
255
  def _git_ref(self, cwd: Union[None, Path] = None) -> str:
218
256
  """Get the commit revision of HEAD.
219
257
 
@@ -228,6 +266,7 @@ class BatchFetchGit(TaskBatchFetch):
228
266
  return output
229
267
 
230
268
  def _update_current_branch_name(self) -> None:
269
+ """Update instance variables with the current branch and commit."""
231
270
  try:
232
271
  # This returns the branch name
233
272
  stdout, _ = self._run(["git", "symbolic-ref", "--short", "HEAD"])
@@ -236,6 +275,7 @@ class BatchFetchGit(TaskBatchFetch):
236
275
  except (IndexError, subprocess.CalledProcessError):
237
276
  # Not a symbolic ref
238
277
  self.current_branch = None
278
+ self.is_branch = False
239
279
 
240
280
  if self.current_branch:
241
281
  try:
@@ -250,6 +290,7 @@ class BatchFetchGit(TaskBatchFetch):
250
290
  pass
251
291
 
252
292
  def _repo_delete(self) -> None:
293
+ """Delete the local Git repository directory safely."""
253
294
  if not self.git_local_dir.exists():
254
295
  self.add_output(self.indent_spaces + "[INFO] Already deleted\n")
255
296
  elif not self.git_local_dir.joinpath(".git").is_dir():
@@ -266,6 +307,7 @@ class BatchFetchGit(TaskBatchFetch):
266
307
  self.set_changed(True)
267
308
 
268
309
  def _repo_clone(self) -> None:
310
+ """Execute git clone for the task repository."""
269
311
  git_clone_args = self["git_clone_args"]
270
312
  # git_clone_args += ["--recurse-submodules"]
271
313
 
@@ -275,14 +317,16 @@ class BatchFetchGit(TaskBatchFetch):
275
317
  self.set_changed(True)
276
318
 
277
319
  def _repo_fetch(self) -> bool:
320
+ """Fetch the remote repository and determine if an update is needed."""
278
321
  # Merge
279
322
  do_git_fetch = self["git_pull"]
280
- do_git_fetch = False
281
323
 
282
324
  try:
283
325
  # Check if the revision such as
284
326
  _, _ = self._run(["git", "cat-file", "-e", self["revision"]])
285
- except subprocess.CalledProcessError:
327
+ except subprocess.CalledProcessError as err:
328
+ logging.debug("Ignoring subprocess error checking cat-file: %s",
329
+ err)
286
330
  do_git_fetch = True
287
331
  self.add_output(
288
332
  self.indent_spaces
@@ -303,8 +347,11 @@ class BatchFetchGit(TaskBatchFetch):
303
347
  + f"{self['revision']} is a branch, not a tag"
304
348
  + "\n")
305
349
  do_git_fetch = True
306
- except subprocess.CalledProcessError:
307
- pass
350
+ except subprocess.CalledProcessError as err:
351
+ logging.debug(
352
+ "Ignoring subprocess error checking show-ref: %s",
353
+ err,
354
+ )
308
355
 
309
356
  if not do_git_fetch:
310
357
  self.add_output(self.indent_spaces + "[INFO] git fetch ignored\n")
@@ -323,6 +370,7 @@ class BatchFetchGit(TaskBatchFetch):
323
370
  return False
324
371
 
325
372
  def _git_apply_update_strategy(self) -> bool:
373
+ """Merge, rebase, or reset local branches according to the strategy."""
326
374
  git_updated = False
327
375
 
328
376
  # Merge, Rebase, or Reset
@@ -343,34 +391,44 @@ class BatchFetchGit(TaskBatchFetch):
343
391
  "prevent data loss."
344
392
  )
345
393
 
394
+ # Retrieve upstream branch or raise exception if not found
395
+ upstream_branch = self._get_upstream_branch()
396
+
346
397
  self.add_output(
347
398
  self.indent_spaces
348
399
  + f"[INFO] Applying update strategy: {strategy} from "
349
- f"origin/{self.current_branch}\n"
400
+ f"{upstream_branch}\n"
350
401
  )
351
402
 
352
403
  if strategy == "rebase":
353
- self._run(["git", "rebase"] +
354
- [f"origin/{self.current_branch}"])
404
+ self._run(["git", "rebase"] + [upstream_branch])
355
405
  elif strategy == "reset":
356
- self._run(["git", "reset", "--hard",
357
- f"origin/{self.current_branch}"])
406
+ self._run(["git", "reset", "--hard", upstream_branch])
358
407
  else:
359
408
  self._run(["git", "merge"] + self["git_merge_args"] +
360
- [f"origin/{self.current_branch}"])
409
+ [upstream_branch])
361
410
 
362
411
  git_ref_after_update = self._git_ref(cwd=self.git_local_dir)
363
412
  if commit_ref_head != git_ref_after_update:
364
413
  git_updated = True
365
414
  self.set_changed(True)
366
- self._run(["git", "log",
367
- '--pretty=format:"%h %ad %s [%cn]"',
368
- "--decorate", "--date=short",
369
- f"{commit_ref_head}..{git_ref_after_update}"])
415
+ stdout, _ = self._run([
416
+ "git", "log",
417
+ "--pretty=format:%h %ad %s [%cn]",
418
+ "--decorate", "--date=short",
419
+ f"{commit_ref_head}..{git_ref_after_update}"
420
+ ])
421
+ if stdout:
422
+ self.add_output(self.indent_spaces +
423
+ "[INFO] Updated commits:\n")
424
+ for line in stdout:
425
+ self.add_output(self.indent_spaces +
426
+ " " + line + "\n")
370
427
 
371
428
  return git_updated
372
429
 
373
430
  def _git_get_remote_url(self, remote_name: str = "origin") -> str:
431
+ """Retrieve the configured url for the given remote name."""
374
432
  origin_url = ""
375
433
  try:
376
434
  stdout, _ = self._run(["git", "config",
@@ -384,6 +442,7 @@ class BatchFetchGit(TaskBatchFetch):
384
442
 
385
443
  def _git_set_remote_url(self, url: str,
386
444
  remote_name: str = "origin") -> str:
445
+ """Reconfigure or add a Git remote with a new target url."""
387
446
  origin_url = ""
388
447
  try:
389
448
  self._run(["git", "remote", "remove", remote_name])
@@ -418,6 +477,7 @@ class BatchFetchGit(TaskBatchFetch):
418
477
  return False
419
478
 
420
479
  def _git_rev_parse_verify(self, revision: str) -> list[str]:
480
+ """Verify and return the parsed reference for a git revision."""
421
481
  stdout: list[str] = []
422
482
  error = False
423
483
  try:
@@ -434,6 +494,7 @@ class BatchFetchGit(TaskBatchFetch):
434
494
  return stdout
435
495
 
436
496
  def _repo_fix_branch(self) -> bool:
497
+ """Check out the correct revision branch if current branch differs."""
437
498
  git_ref_after_merge = self._git_ref(cwd=self.git_local_dir)
438
499
  branch_changed = False
439
500
  if self["revision"]:
@@ -441,24 +502,37 @@ class BatchFetchGit(TaskBatchFetch):
441
502
  # returns a different commit revision
442
503
  git_tags, _ = self._run(["git", "tag", "--points-at", "HEAD"])
443
504
 
505
+ is_branch = False
444
506
  # Also check the commit revision in case
445
507
  # branch is a commit revision instead of a tag
446
508
  try:
447
- # Check if the branch exists
509
+ # Check if the branch exists remotely
448
510
  git_ref_branch = self._git_rev_parse_verify("origin/"
449
511
  + self["revision"]
450
512
  + "^{commit}")[0]
513
+ is_branch = True
451
514
  except GitRevisionDoesNotExist:
452
515
  # Check if the commit ref exists
453
516
  try:
454
517
  git_ref_branch = \
455
518
  self._git_rev_parse_verify(self["revision"])[0]
519
+ if self._git_is_local_branch(self["revision"]):
520
+ is_branch = True
456
521
  except GitRevisionDoesNotExist as err:
457
522
  raise BatchFetchError(f"The branch '{self['revision']}' "
458
523
  "does not exist.") from err
459
524
 
460
- if git_ref_after_merge != git_ref_branch and \
461
- self["revision"] not in git_tags:
525
+ needs_checkout = False
526
+ if self.current_branch != self["revision"]:
527
+ if is_branch:
528
+ needs_checkout = True
529
+ elif self.current_branch is not None:
530
+ needs_checkout = True
531
+ elif git_ref_after_merge != git_ref_branch and \
532
+ self["revision"] not in git_tags:
533
+ needs_checkout = True
534
+
535
+ if needs_checkout:
462
536
  # Update the branch
463
537
  self._run(["git", "checkout"] + [self["revision"]])
464
538
  self.add_output(self.indent_spaces
@@ -473,6 +547,7 @@ class BatchFetchGit(TaskBatchFetch):
473
547
  return branch_changed
474
548
 
475
549
  def _git_fetch_origin(self) -> None:
550
+ """Execute `git fetch origin` if it has not already been done."""
476
551
  # Fetch
477
552
  if not self._git_fetch_origin_done:
478
553
  cmd = ["git", "fetch", "origin"]
@@ -480,6 +555,7 @@ class BatchFetchGit(TaskBatchFetch):
480
555
  self._git_fetch_origin_done = True
481
556
 
482
557
  def _repo_fix_remote_origin(self) -> None:
558
+ """Ensure remote origin URL matches configuration and upstream sets."""
483
559
  correct_origin_url = self[self.main_key]
484
560
  update_remote_origin = False
485
561
 
@@ -491,6 +567,15 @@ class BatchFetchGit(TaskBatchFetch):
491
567
  update_remote_origin = True
492
568
 
493
569
  if update_remote_origin:
570
+ # We must fetch the upstream branch BEFORE updating the remote,
571
+ # because removing the remote drops the tracking configuration.
572
+ upstream_branch = None
573
+ if self.current_branch:
574
+ try:
575
+ upstream_branch = self._get_upstream_branch()
576
+ except BatchFetchError:
577
+ pass
578
+
494
579
  # Update remote
495
580
  try:
496
581
  self._git_set_remote_url(correct_origin_url)
@@ -501,17 +586,20 @@ class BatchFetchGit(TaskBatchFetch):
501
586
  # Get the current branch
502
587
  if self.current_branch:
503
588
  try:
589
+ target_upstream = (upstream_branch if upstream_branch
590
+ else f"origin/{self.current_branch}")
591
+
504
592
  self.add_output(
505
593
  self.indent_spaces
506
594
  + "[INFO] Git fetch origin reason: "
507
595
  f"we need to set the upstream "
508
- f"origin to {self.current_branch}"
596
+ f"origin to {target_upstream}"
509
597
  "\n"
510
598
  )
511
599
  self._git_fetch_origin()
512
600
 
513
601
  cmd = ["git", "branch",
514
- f"--set-upstream-to=origin/{self.current_branch}"]
602
+ f"--set-upstream-to={target_upstream}"]
515
603
  _, _ = self._run(cmd)
516
604
  # TODO: handle errors
517
605
  except subprocess.CalledProcessError as err:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batchfetch
3
- Version: 1.3.7
3
+ Version: 1.3.9
4
4
  Summary: Efficiently clone and pull multiple Git repositories.
5
5
  Home-page: https://github.com/jamescherti/batchfetch
6
6
  Author: James Cherti
@@ -80,7 +80,7 @@ tasks:
80
80
  delete: true
81
81
  ```
82
82
 
83
- Execute the `batchfetch` command from the same directory as `batchfetch.yml` to make it clone or update the local copies of the repositories above.
83
+ Execute the `batchfetch` command from the same directory as `batchfetch.yaml` to make it clone or update the local copies of the repositories above.
84
84
 
85
85
  ## Command-line options
86
86
 
@@ -94,7 +94,7 @@ Efficiently clone/pull multiple Git repositories in parallel.
94
94
  positional arguments:
95
95
  target This is a target path that batchfetch is supposed to
96
96
  handle. When no target is specified, execute the tasks
97
- of all target paths defined in the batchfetch.yml list
97
+ of all target paths defined in the batchfetch.yaml list
98
98
  of tasks.
99
99
 
100
100
  options:
@@ -119,7 +119,7 @@ options:
119
119
  ## Features
120
120
 
121
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.
122
- - Parallel Operations: Utilizes threads to simultaneously Git clone or pull multiple repositories, dramatically reducing wait times.
122
+ - Parallel Operations: Uses threads to simultaneously Git clone or pull multiple repositories, reducing wait times.
123
123
  - User-Friendly Interface: Provides simple and straightforward command-line options that make it easy to get started and effectively manage your repositories.
124
124
  - Custom Configuration: Allows the use of a YAML configuration file to specify and manage the repositories you interact with, enabling repeatable setups and consistent environments.
125
125
  - Detect files that should not be present in directories managed by batchfetch, known as untracked files.
@@ -173,20 +173,6 @@ tasks:
173
173
  revision: b9c6d9b6134b4981760893254f804a371ffbc899
174
174
  ```
175
175
 
176
- ### How to configure additional Git remotes?
177
-
178
- You can define additional remotes for a Git repository using the `remote` option. Pass a list of key-value pairs where the key is the remote name and the value is the corresponding URL. Batchfetch will automatically ensure these remotes are present and point to the correct URLs.
179
-
180
- ```yaml
181
- ---
182
- tasks:
183
- - git: https://github.com/jamescherti/easysession.el
184
- path: emacs/easysession
185
- remote:
186
- upstream: https://github.com/name/repo
187
- upstream2: https://github.com/name/repo2
188
- ```
189
-
190
176
  ### How to customize Git clone, merge, and update strategies?
191
177
 
192
178
  Batchfetch allows you to define advanced options for interacting with Git repositories. These parameters can be declared globally under the `options` block to apply to all tasks, or individually within a specific task to override the global defaults.
@@ -219,6 +205,20 @@ tasks:
219
205
 
220
206
  ```
221
207
 
208
+ ### How to configure additional Git remotes?
209
+
210
+ You can define additional remotes for a Git repository using the `remote` option. Pass a list of key-value pairs where the key is the remote name and the value is the corresponding URL. Batchfetch will automatically ensure these remotes are present and point to the correct URLs.
211
+
212
+ ```yaml
213
+ ---
214
+ tasks:
215
+ - git: https://github.com/jamescherti/easysession.el
216
+ path: emacs/easysession
217
+ remote:
218
+ upstream: https://github.com/name/repo
219
+ upstream2: https://github.com/name/repo2
220
+ ```
221
+
222
222
  ### How to execute a command before and after a task?
223
223
 
224
224
  To execute a command both before and after a specific task, you can define the `exec_before` and `exec_after` directives within the task configuration. These directives specify commands to be executed at the respective stages of the task lifecycle.
@@ -236,9 +236,9 @@ tasks:
236
236
 
237
237
  ### How to make batchfetch handle only one path?
238
238
 
239
- To configure `batchfetch` to handle a specific path, you can define your tasks in a `batchfetch.yml` file and pass the desired path as an argument to the `batchfetch` command.
239
+ To configure `batchfetch` to handle a specific path, you can define your tasks in a `batchfetch.yaml` file and pass the desired path as an argument to the `batchfetch` command.
240
240
 
241
- #### Example `batchfetch.yml` file:
241
+ #### Example `batchfetch.yaml` file:
242
242
 
243
243
  In the following example, the `easysession` task clones two Git repositories:
244
244
 
@@ -258,7 +258,7 @@ To make `batchfetch` clone only `easysession`, pass its path as an argument:
258
258
  batchfetch easysession
259
259
  ```
260
260
 
261
- This will execute only the task corresponding to the `easysession` path, skipping all others in the `batchfetch.yml` file.
261
+ This will execute only the task corresponding to the `easysession` path, skipping all others in the `batchfetch.yaml` file.
262
262
 
263
263
  ### How can I configure batchfetch to load a file other than batchfetch.yaml?
264
264
 
@@ -1,6 +1,9 @@
1
+ .gitignore
1
2
  LICENSE
2
3
  README.md
4
+ run_tests.sh
3
5
  setup.py
6
+ .github/FUNDING.yml
4
7
  batchfetch/__init__.py
5
8
  batchfetch/batchfetch_base.py
6
9
  batchfetch/batchfetch_cli.py
@@ -12,4 +15,6 @@ batchfetch.egg-info/dependency_links.txt
12
15
  batchfetch.egg-info/entry_points.txt
13
16
  batchfetch.egg-info/requires.txt
14
17
  batchfetch.egg-info/top_level.txt
15
- tests/test_helpers.py
18
+ tests/test_helpers.py
19
+ tests/data/test-md5sum.txt
20
+ tests/data/test-run_simple.sh
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Copyright (C) 2024-2026 James Cherti
4
+ # URL: https://github.com/jamescherti/batchfetch
5
+ #
6
+ # This program is free software: you can redistribute it and/or modify it under
7
+ # the terms of the GNU General Public License as published by the Free Software
8
+ # Foundation, either version 3 of the License, or (at your option) any later
9
+ # version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful, but WITHOUT
12
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
13
+ # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License along with
16
+ # this program. If not, see <https://www.gnu.org/licenses/>.
17
+ #
18
+ cd "$(dirname "${BASH_SOURCE[0]}")"
19
+ export PYTHONPATH="$(pwd)"
20
+ exec pytest -v -v --cov=batchfetch --cov=tests \
21
+ --cov-report=term \
22
+ --cov-report=html:htmlcov \
23
+ tests/test_*py
@@ -22,7 +22,7 @@ from setuptools import find_packages, setup
22
22
 
23
23
  setup(
24
24
  name="batchfetch",
25
- version="1.3.7",
25
+ version="1.3.9",
26
26
  packages=find_packages(),
27
27
  description="Efficiently clone and pull multiple Git repositories.",
28
28
  license="GPLv3",
@@ -0,0 +1,3 @@
1
+ Test 1.
2
+ Test 2.
3
+ Test 3.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+
3
+ cd "$(dirname "${BASH_SOURCE[0]}")"
4
+ cat ./test-md5sum.txt
5
+ cat ./test-md5sum.txt | tail -n 1 >&2
File without changes
File without changes