gitstore 0.1.2__tar.gz → 0.2.0__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,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: gitstore
3
+ Version: 0.2.0
4
+ Summary: Utilities to encrypt files/directories with utilitz and exchange them through GitHub repositories.
5
+ Author: artitzco
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: requests>=2.31.0
10
+ Requires-Dist: utilitz[crypto]
11
+
12
+ # gitstore
13
+
14
+ `gitstore` is a focused Python package for one goal:
15
+
16
+ - upload encrypted files/folders to a GitHub-backed repo
17
+ - download and restore encrypted GitHub files later
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install gitstore
23
+ ```
24
+
25
+ ## Dependency on `utilitz`
26
+
27
+ This project depends on:
28
+
29
+ - `utilitz[crypto]`
30
+ - `requests`
31
+
32
+ ## Project Structure
33
+
34
+ ```text
35
+ gitstore/
36
+ src/gitstore/
37
+ __init__.py
38
+ client.py
39
+ config.py
40
+ crypto_ops.py
41
+ github_ops.py
42
+ pyproject.toml
43
+ README.md
44
+ ```
45
+
46
+ ## Core API
47
+
48
+ ```python
49
+ from gitstore import upload_to_github, download_from_github
50
+ ```
51
+
52
+ ## Upload
53
+
54
+ ```python
55
+ from gitstore import upload_to_github
56
+
57
+ record = upload_to_github(
58
+ source_path="C:/data/documento.pdf", # file or directory
59
+ name="documento_ventas_q2", # logical name only
60
+ repo_path="C:/repos/my-publish-repo", # required
61
+ password=None, # default: uses GITSTORE_PASSWORD
62
+ vault_dir="vault", # default
63
+ request_timeout=60, # default
64
+ security_level="high", # default
65
+ replace_existing=True, # default
66
+ force_upload=False, # default
67
+ commit_message=None, # default: automatic message
68
+ )
69
+ print(record)
70
+ ```
71
+
72
+ Upload behavior:
73
+
74
+ - computes `source_hash` from source content before encryption
75
+ - skips upload if same `name` already has same `source_hash`
76
+ - set `force_upload=True` to upload even when the current source matches the remote metadata
77
+ - stores artifact as `vault/<name>.asc`
78
+ - stores metadata in `vault/index.json`
79
+ - removes temporary encrypted file after processing
80
+
81
+ ## Valid Names
82
+
83
+ `name` is the logical identifier used to upload an artifact.
84
+ It is intentionally strict to keep Git paths predictable:
85
+
86
+ - allowed characters: letters, numbers, dots, underscores, and hyphens
87
+ - must start with a letter or number
88
+ - spaces and path separators are not allowed
89
+
90
+ Valid examples:
91
+
92
+ ```text
93
+ documento_ventas_q2
94
+ maindb-version-0.1
95
+ backup.2026_05
96
+ ```
97
+
98
+ Invalid examples:
99
+
100
+ ```text
101
+ maindb -version 0.1
102
+ ../secret
103
+ folder/documento
104
+ ```
105
+
106
+ ## Download
107
+
108
+ ```python
109
+ from gitstore import download_from_github
110
+
111
+ output_path = download_from_github(
112
+ github_url="https://github.com/USER/REPO/blob/main/vault/documento_ventas_q2.asc",
113
+ password=None, # default: uses GITSTORE_PASSWORD
114
+ output_path=None, # default: restores in the current working directory
115
+ overwrite=False, # default
116
+ force_download=False, # default
117
+ )
118
+ print(output_path)
119
+ ```
120
+
121
+ Download behavior:
122
+
123
+ - accepts normal GitHub file URLs (`github.com/.../blob/...`) and raw URLs
124
+ - skips download when `output_path` already exists and matches the remote `source_hash` in `vault/index.json`
125
+ - set `force_download=True` to download even when the local output appears aligned
126
+ - downloads the encrypted `.asc` file to a temporary location
127
+ - restores files or directories automatically with `utilitz.crypto`
128
+ - removes the temporary encrypted file after restore
129
+
130
+ ## Password Source
131
+
132
+ `upload_to_github` and `download_from_github` auto-detect password from:
133
+
134
+ - `GITSTORE_PASSWORD`
135
+
136
+ If `password` is not passed, the environment variable is used.
@@ -0,0 +1,125 @@
1
+ # gitstore
2
+
3
+ `gitstore` is a focused Python package for one goal:
4
+
5
+ - upload encrypted files/folders to a GitHub-backed repo
6
+ - download and restore encrypted GitHub files later
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pip install gitstore
12
+ ```
13
+
14
+ ## Dependency on `utilitz`
15
+
16
+ This project depends on:
17
+
18
+ - `utilitz[crypto]`
19
+ - `requests`
20
+
21
+ ## Project Structure
22
+
23
+ ```text
24
+ gitstore/
25
+ src/gitstore/
26
+ __init__.py
27
+ client.py
28
+ config.py
29
+ crypto_ops.py
30
+ github_ops.py
31
+ pyproject.toml
32
+ README.md
33
+ ```
34
+
35
+ ## Core API
36
+
37
+ ```python
38
+ from gitstore import upload_to_github, download_from_github
39
+ ```
40
+
41
+ ## Upload
42
+
43
+ ```python
44
+ from gitstore import upload_to_github
45
+
46
+ record = upload_to_github(
47
+ source_path="C:/data/documento.pdf", # file or directory
48
+ name="documento_ventas_q2", # logical name only
49
+ repo_path="C:/repos/my-publish-repo", # required
50
+ password=None, # default: uses GITSTORE_PASSWORD
51
+ vault_dir="vault", # default
52
+ request_timeout=60, # default
53
+ security_level="high", # default
54
+ replace_existing=True, # default
55
+ force_upload=False, # default
56
+ commit_message=None, # default: automatic message
57
+ )
58
+ print(record)
59
+ ```
60
+
61
+ Upload behavior:
62
+
63
+ - computes `source_hash` from source content before encryption
64
+ - skips upload if same `name` already has same `source_hash`
65
+ - set `force_upload=True` to upload even when the current source matches the remote metadata
66
+ - stores artifact as `vault/<name>.asc`
67
+ - stores metadata in `vault/index.json`
68
+ - removes temporary encrypted file after processing
69
+
70
+ ## Valid Names
71
+
72
+ `name` is the logical identifier used to upload an artifact.
73
+ It is intentionally strict to keep Git paths predictable:
74
+
75
+ - allowed characters: letters, numbers, dots, underscores, and hyphens
76
+ - must start with a letter or number
77
+ - spaces and path separators are not allowed
78
+
79
+ Valid examples:
80
+
81
+ ```text
82
+ documento_ventas_q2
83
+ maindb-version-0.1
84
+ backup.2026_05
85
+ ```
86
+
87
+ Invalid examples:
88
+
89
+ ```text
90
+ maindb -version 0.1
91
+ ../secret
92
+ folder/documento
93
+ ```
94
+
95
+ ## Download
96
+
97
+ ```python
98
+ from gitstore import download_from_github
99
+
100
+ output_path = download_from_github(
101
+ github_url="https://github.com/USER/REPO/blob/main/vault/documento_ventas_q2.asc",
102
+ password=None, # default: uses GITSTORE_PASSWORD
103
+ output_path=None, # default: restores in the current working directory
104
+ overwrite=False, # default
105
+ force_download=False, # default
106
+ )
107
+ print(output_path)
108
+ ```
109
+
110
+ Download behavior:
111
+
112
+ - accepts normal GitHub file URLs (`github.com/.../blob/...`) and raw URLs
113
+ - skips download when `output_path` already exists and matches the remote `source_hash` in `vault/index.json`
114
+ - set `force_download=True` to download even when the local output appears aligned
115
+ - downloads the encrypted `.asc` file to a temporary location
116
+ - restores files or directories automatically with `utilitz.crypto`
117
+ - removes the temporary encrypted file after restore
118
+
119
+ ## Password Source
120
+
121
+ `upload_to_github` and `download_from_github` auto-detect password from:
122
+
123
+ - `GITSTORE_PASSWORD`
124
+
125
+ If `password` is not passed, the environment variable is used.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitstore"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "Utilities to encrypt files/directories with utilitz and exchange them through GitHub repositories."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,13 @@
1
+ from .client import (
2
+ DEFAULT_PASSWORD_ENV_VAR,
3
+ StoredArtifact,
4
+ download_from_github,
5
+ upload_to_github,
6
+ )
7
+
8
+ __all__ = [
9
+ "StoredArtifact",
10
+ "download_from_github",
11
+ "upload_to_github",
12
+ "DEFAULT_PASSWORD_ENV_VAR",
13
+ ]
@@ -0,0 +1,298 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+ import hashlib
6
+ import hmac
7
+ import re
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ from .config import GitStoreConfig
13
+ from .github_ops import download_raw_file, git_add_commit_push, normalize_github_file_url
14
+ from .crypto_ops import decrypt_auto, encrypt_directory, encrypt_file
15
+
16
+ DEFAULT_PASSWORD_ENV_VAR = "GITSTORE_PASSWORD"
17
+ _VALID_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
18
+ _NAME_RULES_MESSAGE = (
19
+ "name must be a simple identifier using only letters, numbers, dots, "
20
+ "underscores, and hyphens. It must start with a letter or number and must "
21
+ "not contain spaces or path separators."
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class StoredArtifact:
27
+ name: str
28
+ artifact: str
29
+ artifact_hash: str
30
+ source_hash: str
31
+ is_directory: bool
32
+ created_at_utc: str
33
+
34
+
35
+ def _resolve_password(password: str | None, password_env_var: str) -> str:
36
+ resolved_password = password or os.getenv(password_env_var)
37
+ if not resolved_password:
38
+ raise ValueError(
39
+ "Password was not provided and no environment variable was found. "
40
+ f"Set '{password_env_var}' or pass password explicitly."
41
+ )
42
+ return resolved_password
43
+
44
+
45
+ def _validate_name(name: str) -> str:
46
+ if not isinstance(name, str) or not name:
47
+ raise ValueError(_NAME_RULES_MESSAGE)
48
+ if name != name.strip():
49
+ raise ValueError(_NAME_RULES_MESSAGE)
50
+ if any(sep in name for sep in ("/", "\\")):
51
+ raise ValueError(_NAME_RULES_MESSAGE)
52
+ if not _VALID_NAME_RE.fullmatch(name):
53
+ raise ValueError(_NAME_RULES_MESSAGE)
54
+ return name
55
+
56
+
57
+ def _hash_from_name_or_content(encrypted_path: Path) -> str:
58
+ # utilitz default output name pattern: -confidential-<24hex>.asc
59
+ m = re.search(r"-confidential-([0-9a-fA-F]{24})", encrypted_path.name)
60
+ if m:
61
+ return m.group(1).lower()
62
+
63
+ hasher = hashlib.sha256()
64
+ with open(encrypted_path, "rb") as f:
65
+ for chunk in iter(lambda: f.read(65536), b""):
66
+ hasher.update(chunk)
67
+ return hasher.hexdigest()[:24]
68
+
69
+
70
+ def _hmac_source(path: Path, secret: str) -> str:
71
+ key = hashlib.sha256(secret.encode("utf-8")).digest()
72
+
73
+ if path.is_file():
74
+ mac = hmac.new(key, digestmod=hashlib.sha256)
75
+ mac.update(b"F:")
76
+ mac.update(path.name.encode("utf-8"))
77
+ mac.update(b"\0")
78
+ with open(path, "rb") as f:
79
+ for chunk in iter(lambda: f.read(65536), b""):
80
+ mac.update(chunk)
81
+ return mac.hexdigest()
82
+
83
+ if not path.is_dir():
84
+ raise FileNotFoundError(f"Input path not found: {path}")
85
+
86
+ mac = hmac.new(key, digestmod=hashlib.sha256)
87
+ for file_path in sorted(p for p in path.rglob("*") if p.is_file()):
88
+ rel = file_path.relative_to(path).as_posix()
89
+ mac.update(b"F:")
90
+ mac.update(rel.encode("utf-8"))
91
+ mac.update(b"\0")
92
+ with open(file_path, "rb") as f:
93
+ for chunk in iter(lambda: f.read(65536), b""):
94
+ mac.update(chunk)
95
+ mac.update(b"\n")
96
+ return mac.hexdigest()
97
+
98
+
99
+ def download_from_github(
100
+ github_url: str,
101
+ password: str | None = None,
102
+ output_path: str | None = None,
103
+ overwrite: bool = False,
104
+ force_download: bool = False,
105
+ request_timeout: int = 60,
106
+ password_env_var: str = DEFAULT_PASSWORD_ENV_VAR,
107
+ ) -> str:
108
+ resolved_password = _resolve_password(password, password_env_var)
109
+ config = GitStoreConfig(password=resolved_password, request_timeout=request_timeout)
110
+ raw_url = normalize_github_file_url(github_url)
111
+ if output_path is not None and not overwrite and not force_download:
112
+ existing_output = Path(output_path).expanduser().resolve()
113
+ if existing_output.exists():
114
+ remote_record = _load_remote_record_for_artifact(raw_url, request_timeout)
115
+ remote_source_hash = str(remote_record.get("source_hash") or "").lower()
116
+ if remote_source_hash and _hmac_source(existing_output, config.password).lower() == remote_source_hash:
117
+ print(f"[gitstore] Skip download: '{raw_url}' already restored at '{existing_output}'.")
118
+ return str(existing_output)
119
+
120
+ temp_dir = None
121
+ if output_path is None:
122
+ fd, temp_name = tempfile.mkstemp(
123
+ prefix=".gitstore_download_",
124
+ suffix=".asc",
125
+ dir=Path.cwd(),
126
+ )
127
+ os.close(fd)
128
+ temp_file = Path(temp_name)
129
+ else:
130
+ temp_dir = Path(tempfile.mkdtemp(prefix="gitstore_download_"))
131
+ temp_file = temp_dir / "artifact.asc"
132
+ try:
133
+ download_raw_file(raw_url, str(temp_file), timeout=request_timeout)
134
+ restored_path = decrypt_auto(
135
+ encrypted_path=str(temp_file),
136
+ config=config,
137
+ output_path=output_path,
138
+ overwrite=overwrite,
139
+ )
140
+ print(f"[gitstore] Downloaded and restored '{raw_url}' -> '{restored_path}'.")
141
+ return restored_path
142
+ finally:
143
+ temp_file.unlink(missing_ok=True)
144
+ if temp_dir is not None:
145
+ shutil.rmtree(temp_dir, ignore_errors=True)
146
+
147
+
148
+ def _load_remote_record_for_artifact(raw_url: str, request_timeout: int) -> dict:
149
+ manifest_url = raw_url.rsplit("/", 1)[0] + "/index.json"
150
+ artifact_name = raw_url.rsplit("/", 1)[-1]
151
+ temp_dir = Path(tempfile.mkdtemp(prefix="gitstore_manifest_"))
152
+ temp_manifest = temp_dir / "index.json"
153
+ try:
154
+ try:
155
+ download_raw_file(manifest_url, str(temp_manifest), timeout=request_timeout)
156
+ with open(temp_manifest, "r", encoding="utf-8") as f:
157
+ manifest = json.load(f)
158
+ except Exception:
159
+ return {}
160
+ finally:
161
+ shutil.rmtree(temp_dir, ignore_errors=True)
162
+
163
+ if not isinstance(manifest, dict):
164
+ return {}
165
+ for record in manifest.values():
166
+ if isinstance(record, dict) and record.get("artifact") == artifact_name:
167
+ return record
168
+ return {}
169
+
170
+
171
+ def upload_to_github(
172
+ source_path: str,
173
+ name: str,
174
+ repo_path: str,
175
+ password: str | None = None,
176
+ vault_dir: str = "vault",
177
+ request_timeout: int = 60,
178
+ password_env_var: str = DEFAULT_PASSWORD_ENV_VAR,
179
+ security_level: str = "high",
180
+ commit_message: str | None = None,
181
+ replace_existing: bool = True,
182
+ force_upload: bool = False,
183
+ ) -> StoredArtifact:
184
+ if not repo_path:
185
+ raise ValueError("repo_path is required.")
186
+
187
+ name = _validate_name(name)
188
+ resolved_password = _resolve_password(password, password_env_var)
189
+ config = GitStoreConfig(password=resolved_password, request_timeout=request_timeout)
190
+ repo = Path(repo_path).expanduser().resolve()
191
+ if not (repo / ".git").exists():
192
+ raise FileNotFoundError(f"Not a git repository: {repo}")
193
+
194
+ vault = vault_dir.strip().strip("/\\") or "vault"
195
+ manifest_rel = f"{vault}/index.json"
196
+ manifest_path = repo / manifest_rel
197
+
198
+ source = Path(source_path).expanduser().resolve()
199
+ if not source.exists():
200
+ raise FileNotFoundError(f"Input path not found: {source}")
201
+
202
+ is_directory = source.is_dir()
203
+ source_hash = _hmac_source(source, config.password)
204
+ manifest = _load_manifest_local(manifest_path)
205
+ existing_record = manifest.get(name)
206
+ if existing_record:
207
+ existing_source_hash = str(existing_record.get("source_hash", "")).lower()
208
+ if existing_source_hash == source_hash.lower() and not force_upload:
209
+ print(
210
+ f"[gitstore] Skip upload: '{name}' already up to date "
211
+ f"(source_hash={source_hash[:24]})."
212
+ )
213
+ return StoredArtifact(
214
+ name=name,
215
+ artifact=str(existing_record["artifact"]),
216
+ artifact_hash=str(existing_record.get("artifact_hash", "")),
217
+ source_hash=existing_source_hash,
218
+ is_directory=bool(existing_record["is_directory"]),
219
+ created_at_utc=str(existing_record["created_at_utc"]),
220
+ )
221
+ if not replace_existing:
222
+ raise ValueError(f"Name '{name}' already exists. Use replace_existing=True to replace it.")
223
+
224
+ if is_directory:
225
+ encrypted_path = Path(
226
+ encrypt_directory(
227
+ source_directory=str(source),
228
+ config=config,
229
+ security_level=security_level,
230
+ )
231
+ )
232
+ else:
233
+ encrypted_path = Path(
234
+ encrypt_file(
235
+ source_path=str(source),
236
+ config=config,
237
+ security_level=security_level,
238
+ )
239
+ )
240
+
241
+ try:
242
+ extension = "".join(encrypted_path.suffixes) or ".asc"
243
+ artifact_hash = _hash_from_name_or_content(encrypted_path)
244
+ artifact_filename = f"{name}{extension}"
245
+ artifact_rel = f"{vault}/{artifact_filename}"
246
+ artifact_abs = repo / artifact_rel
247
+ artifact_abs.parent.mkdir(parents=True, exist_ok=True)
248
+
249
+ shutil.copy2(encrypted_path, artifact_abs)
250
+
251
+ record = StoredArtifact(
252
+ name=name,
253
+ artifact=artifact_filename,
254
+ artifact_hash=artifact_hash,
255
+ source_hash=source_hash,
256
+ is_directory=is_directory,
257
+ created_at_utc=datetime.now(timezone.utc).isoformat(),
258
+ )
259
+ manifest[name] = {
260
+ "name": record.name,
261
+ "artifact": record.artifact,
262
+ "artifact_hash": record.artifact_hash,
263
+ "source_hash": record.source_hash,
264
+ "is_directory": record.is_directory,
265
+ "created_at_utc": record.created_at_utc,
266
+ }
267
+ _save_manifest_local(manifest_path, manifest)
268
+
269
+ message = commit_message or f"gitstore: store '{name}'"
270
+ paths_to_commit = [artifact_rel, manifest_rel]
271
+ git_add_commit_push(
272
+ repo_path=str(repo),
273
+ paths_in_repo=paths_to_commit,
274
+ commit_message=message,
275
+ )
276
+ print(f"[gitstore] Uploaded '{name}' -> '{artifact_rel}' (hash={artifact_hash}).")
277
+ return record
278
+ finally:
279
+ if encrypted_path.exists():
280
+ encrypted_path.unlink()
281
+
282
+
283
+ def _load_manifest_local(manifest_path: Path) -> dict:
284
+ if not manifest_path.exists():
285
+ return {}
286
+ with open(manifest_path, "r", encoding="utf-8") as f:
287
+ data = json.load(f)
288
+ if not isinstance(data, dict):
289
+ raise ValueError("Manifest format is invalid.")
290
+ return data
291
+
292
+
293
+ def _save_manifest_local(manifest_path: Path, manifest: dict) -> None:
294
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
295
+ with open(manifest_path, "w", encoding="utf-8", newline="\n") as f:
296
+ json.dump(manifest, f, indent=2, sort_keys=False)
297
+ f.write("\n")
298
+
@@ -76,18 +76,42 @@ def decrypt_file(
76
76
  )
77
77
 
78
78
 
79
- def decrypt_directory(
80
- encrypted_path: str,
81
- config: GitStoreConfig,
79
+ def decrypt_directory(
80
+ encrypted_path: str,
81
+ config: GitStoreConfig,
82
82
  output_path: str | None = None,
83
83
  overwrite: bool = False,
84
84
  ) -> str:
85
85
  path = Path(encrypted_path).expanduser().resolve()
86
86
  if not path.is_file():
87
87
  raise FileNotFoundError(f"Encrypted file not found: {path}")
88
- return _crypto().decrypt_directory(
89
- encrypted_file=str(path),
90
- password=config.password,
91
- output_path=output_path,
92
- overwrite=overwrite,
93
- )
88
+ return _crypto().decrypt_directory(
89
+ encrypted_file=str(path),
90
+ password=config.password,
91
+ output_path=output_path,
92
+ overwrite=overwrite,
93
+ )
94
+
95
+
96
+ def decrypt_auto(
97
+ encrypted_path: str,
98
+ config: GitStoreConfig,
99
+ output_path: str | None = None,
100
+ overwrite: bool = False,
101
+ ) -> str:
102
+ try:
103
+ return decrypt_file(
104
+ encrypted_path=encrypted_path,
105
+ config=config,
106
+ output_path=output_path,
107
+ overwrite=overwrite,
108
+ )
109
+ except ValueError as exc:
110
+ if "directory archive" not in str(exc):
111
+ raise
112
+ return decrypt_directory(
113
+ encrypted_path=encrypted_path,
114
+ config=config,
115
+ output_path=output_path,
116
+ overwrite=overwrite,
117
+ )
@@ -0,0 +1,57 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ import requests
5
+
6
+
7
+ def _run_git(repo: Path, args: list[str], capture: bool = False) -> subprocess.CompletedProcess:
8
+ return subprocess.run(
9
+ ["git", *args],
10
+ cwd=repo,
11
+ check=True,
12
+ capture_output=capture,
13
+ text=capture,
14
+ )
15
+
16
+
17
+ def normalize_github_file_url(url: str) -> str:
18
+ raw_url = (url or "").strip()
19
+ if not raw_url:
20
+ raise ValueError("url is required.")
21
+ if "github.com/" in raw_url and "/blob/" in raw_url:
22
+ return raw_url.replace("https://github.com/", "https://raw.githubusercontent.com/").replace(
23
+ "/blob/",
24
+ "/",
25
+ 1,
26
+ )
27
+ return raw_url
28
+
29
+
30
+ def download_raw_file(raw_url: str, output_path: str, timeout: int = 60) -> str:
31
+ destination = Path(output_path).expanduser().resolve()
32
+ destination.parent.mkdir(parents=True, exist_ok=True)
33
+
34
+ with requests.get(raw_url, timeout=timeout, stream=True) as response:
35
+ response.raise_for_status()
36
+ with open(destination, "wb") as f:
37
+ for chunk in response.iter_content(chunk_size=65536):
38
+ if chunk:
39
+ f.write(chunk)
40
+ return str(destination)
41
+
42
+
43
+ def git_add_commit_push(
44
+ repo_path: str,
45
+ paths_in_repo: list[str],
46
+ commit_message: str,
47
+ ) -> None:
48
+ repo = Path(repo_path).expanduser().resolve()
49
+ if not (repo / ".git").exists():
50
+ raise FileNotFoundError(f"Not a git repository: {repo}")
51
+ if not paths_in_repo:
52
+ raise ValueError("paths_in_repo must not be empty.")
53
+
54
+ _run_git(repo, ["add", *paths_in_repo])
55
+ _run_git(repo, ["commit", "-m", commit_message])
56
+ _run_git(repo, ["push"])
57
+