batchfetch 1.3.7__tar.gz → 1.3.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batchfetch
3
- Version: 1.3.7
3
+ Version: 1.3.8
4
4
  Summary: Efficiently clone and pull multiple Git repositories.
5
5
  Home-page: https://github.com/jamescherti/batchfetch
6
6
  Author: James Cherti
@@ -80,7 +80,7 @@ tasks:
80
80
  delete: true
81
81
  ```
82
82
 
83
- Execute the `batchfetch` command from the same directory as `batchfetch.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:
@@ -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:
@@ -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"])
@@ -250,6 +289,7 @@ class BatchFetchGit(TaskBatchFetch):
250
289
  pass
251
290
 
252
291
  def _repo_delete(self) -> None:
292
+ """Delete the local Git repository directory safely."""
253
293
  if not self.git_local_dir.exists():
254
294
  self.add_output(self.indent_spaces + "[INFO] Already deleted\n")
255
295
  elif not self.git_local_dir.joinpath(".git").is_dir():
@@ -266,6 +306,7 @@ class BatchFetchGit(TaskBatchFetch):
266
306
  self.set_changed(True)
267
307
 
268
308
  def _repo_clone(self) -> None:
309
+ """Execute git clone for the task repository."""
269
310
  git_clone_args = self["git_clone_args"]
270
311
  # git_clone_args += ["--recurse-submodules"]
271
312
 
@@ -275,14 +316,16 @@ class BatchFetchGit(TaskBatchFetch):
275
316
  self.set_changed(True)
276
317
 
277
318
  def _repo_fetch(self) -> bool:
319
+ """Fetch the remote repository and determine if an update is needed."""
278
320
  # Merge
279
321
  do_git_fetch = self["git_pull"]
280
- do_git_fetch = False
281
322
 
282
323
  try:
283
324
  # Check if the revision such as
284
325
  _, _ = self._run(["git", "cat-file", "-e", self["revision"]])
285
- except subprocess.CalledProcessError:
326
+ except subprocess.CalledProcessError as err:
327
+ logging.debug("Ignoring subprocess error checking cat-file: %s",
328
+ err)
286
329
  do_git_fetch = True
287
330
  self.add_output(
288
331
  self.indent_spaces
@@ -303,8 +346,11 @@ class BatchFetchGit(TaskBatchFetch):
303
346
  + f"{self['revision']} is a branch, not a tag"
304
347
  + "\n")
305
348
  do_git_fetch = True
306
- except subprocess.CalledProcessError:
307
- pass
349
+ except subprocess.CalledProcessError as err:
350
+ logging.debug(
351
+ "Ignoring subprocess error checking show-ref: %s",
352
+ err,
353
+ )
308
354
 
309
355
  if not do_git_fetch:
310
356
  self.add_output(self.indent_spaces + "[INFO] git fetch ignored\n")
@@ -323,6 +369,7 @@ class BatchFetchGit(TaskBatchFetch):
323
369
  return False
324
370
 
325
371
  def _git_apply_update_strategy(self) -> bool:
372
+ """Merge, rebase, or reset local branches according to the strategy."""
326
373
  git_updated = False
327
374
 
328
375
  # Merge, Rebase, or Reset
@@ -343,34 +390,44 @@ class BatchFetchGit(TaskBatchFetch):
343
390
  "prevent data loss."
344
391
  )
345
392
 
393
+ # Retrieve upstream branch or raise exception if not found
394
+ upstream_branch = self._get_upstream_branch()
395
+
346
396
  self.add_output(
347
397
  self.indent_spaces
348
398
  + f"[INFO] Applying update strategy: {strategy} from "
349
- f"origin/{self.current_branch}\n"
399
+ f"{upstream_branch}\n"
350
400
  )
351
401
 
352
402
  if strategy == "rebase":
353
- self._run(["git", "rebase"] +
354
- [f"origin/{self.current_branch}"])
403
+ self._run(["git", "rebase"] + [upstream_branch])
355
404
  elif strategy == "reset":
356
- self._run(["git", "reset", "--hard",
357
- f"origin/{self.current_branch}"])
405
+ self._run(["git", "reset", "--hard", upstream_branch])
358
406
  else:
359
407
  self._run(["git", "merge"] + self["git_merge_args"] +
360
- [f"origin/{self.current_branch}"])
408
+ [upstream_branch])
361
409
 
362
410
  git_ref_after_update = self._git_ref(cwd=self.git_local_dir)
363
411
  if commit_ref_head != git_ref_after_update:
364
412
  git_updated = True
365
413
  self.set_changed(True)
366
- self._run(["git", "log",
367
- '--pretty=format:"%h %ad %s [%cn]"',
368
- "--decorate", "--date=short",
369
- f"{commit_ref_head}..{git_ref_after_update}"])
414
+ stdout, _ = self._run([
415
+ "git", "log",
416
+ "--pretty=format:%h %ad %s [%cn]",
417
+ "--decorate", "--date=short",
418
+ f"{commit_ref_head}..{git_ref_after_update}"
419
+ ])
420
+ if stdout:
421
+ self.add_output(self.indent_spaces +
422
+ "[INFO] Updated commits:\n")
423
+ for line in stdout:
424
+ self.add_output(self.indent_spaces +
425
+ " " + line + "\n")
370
426
 
371
427
  return git_updated
372
428
 
373
429
  def _git_get_remote_url(self, remote_name: str = "origin") -> str:
430
+ """Retrieve the configured url for the given remote name."""
374
431
  origin_url = ""
375
432
  try:
376
433
  stdout, _ = self._run(["git", "config",
@@ -384,6 +441,7 @@ class BatchFetchGit(TaskBatchFetch):
384
441
 
385
442
  def _git_set_remote_url(self, url: str,
386
443
  remote_name: str = "origin") -> str:
444
+ """Reconfigure or add a Git remote with a new target url."""
387
445
  origin_url = ""
388
446
  try:
389
447
  self._run(["git", "remote", "remove", remote_name])
@@ -418,6 +476,7 @@ class BatchFetchGit(TaskBatchFetch):
418
476
  return False
419
477
 
420
478
  def _git_rev_parse_verify(self, revision: str) -> list[str]:
479
+ """Verify and return the parsed reference for a git revision."""
421
480
  stdout: list[str] = []
422
481
  error = False
423
482
  try:
@@ -434,6 +493,7 @@ class BatchFetchGit(TaskBatchFetch):
434
493
  return stdout
435
494
 
436
495
  def _repo_fix_branch(self) -> bool:
496
+ """Check out the correct revision branch if current branch differs."""
437
497
  git_ref_after_merge = self._git_ref(cwd=self.git_local_dir)
438
498
  branch_changed = False
439
499
  if self["revision"]:
@@ -441,24 +501,35 @@ class BatchFetchGit(TaskBatchFetch):
441
501
  # returns a different commit revision
442
502
  git_tags, _ = self._run(["git", "tag", "--points-at", "HEAD"])
443
503
 
504
+ is_branch = False
444
505
  # Also check the commit revision in case
445
506
  # branch is a commit revision instead of a tag
446
507
  try:
447
- # Check if the branch exists
508
+ # Check if the branch exists remotely
448
509
  git_ref_branch = self._git_rev_parse_verify("origin/"
449
510
  + self["revision"]
450
511
  + "^{commit}")[0]
512
+ is_branch = True
451
513
  except GitRevisionDoesNotExist:
452
514
  # Check if the commit ref exists
453
515
  try:
454
516
  git_ref_branch = \
455
517
  self._git_rev_parse_verify(self["revision"])[0]
518
+ if self._git_is_local_branch(self["revision"]):
519
+ is_branch = True
456
520
  except GitRevisionDoesNotExist as err:
457
521
  raise BatchFetchError(f"The branch '{self['revision']}' "
458
522
  "does not exist.") from err
459
523
 
460
- if git_ref_after_merge != git_ref_branch and \
461
- self["revision"] not in git_tags:
524
+ needs_checkout = False
525
+ if self.current_branch != self["revision"]:
526
+ if is_branch:
527
+ needs_checkout = True
528
+ elif git_ref_after_merge != git_ref_branch and \
529
+ self["revision"] not in git_tags:
530
+ needs_checkout = True
531
+
532
+ if needs_checkout:
462
533
  # Update the branch
463
534
  self._run(["git", "checkout"] + [self["revision"]])
464
535
  self.add_output(self.indent_spaces
@@ -473,6 +544,7 @@ class BatchFetchGit(TaskBatchFetch):
473
544
  return branch_changed
474
545
 
475
546
  def _git_fetch_origin(self) -> None:
547
+ """Execute `git fetch origin` if it has not already been done."""
476
548
  # Fetch
477
549
  if not self._git_fetch_origin_done:
478
550
  cmd = ["git", "fetch", "origin"]
@@ -480,6 +552,7 @@ class BatchFetchGit(TaskBatchFetch):
480
552
  self._git_fetch_origin_done = True
481
553
 
482
554
  def _repo_fix_remote_origin(self) -> None:
555
+ """Ensure remote origin URL matches configuration and upstream sets."""
483
556
  correct_origin_url = self[self.main_key]
484
557
  update_remote_origin = False
485
558
 
@@ -491,6 +564,15 @@ class BatchFetchGit(TaskBatchFetch):
491
564
  update_remote_origin = True
492
565
 
493
566
  if update_remote_origin:
567
+ # We must fetch the upstream branch BEFORE updating the remote,
568
+ # because removing the remote drops the tracking configuration.
569
+ upstream_branch = None
570
+ if self.current_branch:
571
+ try:
572
+ upstream_branch = self._get_upstream_branch()
573
+ except BatchFetchError:
574
+ pass
575
+
494
576
  # Update remote
495
577
  try:
496
578
  self._git_set_remote_url(correct_origin_url)
@@ -501,17 +583,20 @@ class BatchFetchGit(TaskBatchFetch):
501
583
  # Get the current branch
502
584
  if self.current_branch:
503
585
  try:
586
+ target_upstream = (upstream_branch if upstream_branch
587
+ else f"origin/{self.current_branch}")
588
+
504
589
  self.add_output(
505
590
  self.indent_spaces
506
591
  + "[INFO] Git fetch origin reason: "
507
592
  f"we need to set the upstream "
508
- f"origin to {self.current_branch}"
593
+ f"origin to {target_upstream}"
509
594
  "\n"
510
595
  )
511
596
  self._git_fetch_origin()
512
597
 
513
598
  cmd = ["git", "branch",
514
- f"--set-upstream-to=origin/{self.current_branch}"]
599
+ f"--set-upstream-to={target_upstream}"]
515
600
  _, _ = self._run(cmd)
516
601
  # TODO: handle errors
517
602
  except subprocess.CalledProcessError as err:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batchfetch
3
- Version: 1.3.7
3
+ Version: 1.3.8
4
4
  Summary: Efficiently clone and pull multiple Git repositories.
5
5
  Home-page: https://github.com/jamescherti/batchfetch
6
6
  Author: James Cherti
@@ -80,7 +80,7 @@ tasks:
80
80
  delete: true
81
81
  ```
82
82
 
83
- Execute the `batchfetch` command from the same directory as `batchfetch.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:
@@ -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
 
@@ -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.8",
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