gitstore 0.1.2__tar.gz → 0.2.1__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,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: gitstore
3
+ Version: 0.2.1
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, restore_from_github, restore_from_file
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 restore_from_github
110
+
111
+ output_path = restore_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
+ Local restore (no network):
122
+
123
+ ```python
124
+ from gitstore import restore_from_file
125
+
126
+ output_path = restore_from_file(
127
+ encrypted_file_path="C:/downloads/documento_ventas_q2.asc",
128
+ password=None, # default: uses GITSTORE_PASSWORD
129
+ output_path=None, # optional
130
+ overwrite=False, # default
131
+ )
132
+ print(output_path)
133
+ ```
134
+
135
+ Tip:
136
+
137
+ - use `restore_from_file(...)` when the target machine has SSL/certificate restrictions and you prefer manual transfer of the `.asc` file.
138
+
139
+ Download behavior:
140
+
141
+ - accepts normal GitHub file URLs (`github.com/.../blob/...`) and raw URLs
142
+ - skips download when `output_path` already exists and matches the remote `source_hash` in `vault/index.json`
143
+ - set `force_download=True` to download even when the local output appears aligned
144
+ - downloads the encrypted `.asc` file to a temporary location
145
+ - restores files or directories automatically with `utilitz.crypto`
146
+ - removes the temporary encrypted file after restore
147
+ - supports local decode with `restore_from_file(...)` when manual download is preferred
148
+
149
+ ## Password Source
150
+
151
+ `upload_to_github`, `restore_from_github`, and `restore_from_file` auto-detect password from:
152
+
153
+ - `GITSTORE_PASSWORD`
154
+
155
+ If `password` is not passed, the environment variable is used.
@@ -0,0 +1,144 @@
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, restore_from_github, restore_from_file
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 restore_from_github
99
+
100
+ output_path = restore_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
+ Local restore (no network):
111
+
112
+ ```python
113
+ from gitstore import restore_from_file
114
+
115
+ output_path = restore_from_file(
116
+ encrypted_file_path="C:/downloads/documento_ventas_q2.asc",
117
+ password=None, # default: uses GITSTORE_PASSWORD
118
+ output_path=None, # optional
119
+ overwrite=False, # default
120
+ )
121
+ print(output_path)
122
+ ```
123
+
124
+ Tip:
125
+
126
+ - use `restore_from_file(...)` when the target machine has SSL/certificate restrictions and you prefer manual transfer of the `.asc` file.
127
+
128
+ Download behavior:
129
+
130
+ - accepts normal GitHub file URLs (`github.com/.../blob/...`) and raw URLs
131
+ - skips download when `output_path` already exists and matches the remote `source_hash` in `vault/index.json`
132
+ - set `force_download=True` to download even when the local output appears aligned
133
+ - downloads the encrypted `.asc` file to a temporary location
134
+ - restores files or directories automatically with `utilitz.crypto`
135
+ - removes the temporary encrypted file after restore
136
+ - supports local decode with `restore_from_file(...)` when manual download is preferred
137
+
138
+ ## Password Source
139
+
140
+ `upload_to_github`, `restore_from_github`, and `restore_from_file` auto-detect password from:
141
+
142
+ - `GITSTORE_PASSWORD`
143
+
144
+ 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.1"
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,15 @@
1
+ from .client import (
2
+ DEFAULT_PASSWORD_ENV_VAR,
3
+ StoredArtifact,
4
+ restore_from_github,
5
+ restore_from_file,
6
+ upload_to_github,
7
+ )
8
+
9
+ __all__ = [
10
+ "StoredArtifact",
11
+ "restore_from_github",
12
+ "restore_from_file",
13
+ "upload_to_github",
14
+ "DEFAULT_PASSWORD_ENV_VAR",
15
+ ]
@@ -0,0 +1,320 @@
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 restore_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 restore_from_file(
149
+ encrypted_file_path: str,
150
+ password: str | None = None,
151
+ output_path: str | None = None,
152
+ overwrite: bool = False,
153
+ password_env_var: str = DEFAULT_PASSWORD_ENV_VAR,
154
+ ) -> str:
155
+ resolved_password = _resolve_password(password, password_env_var)
156
+ config = GitStoreConfig(password=resolved_password, request_timeout=60)
157
+ encrypted_file = Path(encrypted_file_path).expanduser().resolve()
158
+ if not encrypted_file.is_file():
159
+ raise FileNotFoundError(f"Encrypted file not found: {encrypted_file}")
160
+ restored_path = decrypt_auto(
161
+ encrypted_path=str(encrypted_file),
162
+ config=config,
163
+ output_path=output_path,
164
+ overwrite=overwrite,
165
+ )
166
+ print(f"[gitstore] Restored local artifact '{encrypted_file}' -> '{restored_path}'.")
167
+ return restored_path
168
+
169
+
170
+ def _load_remote_record_for_artifact(raw_url: str, request_timeout: int) -> dict:
171
+ manifest_url = raw_url.rsplit("/", 1)[0] + "/index.json"
172
+ artifact_name = raw_url.rsplit("/", 1)[-1]
173
+ temp_dir = Path(tempfile.mkdtemp(prefix="gitstore_manifest_"))
174
+ temp_manifest = temp_dir / "index.json"
175
+ try:
176
+ try:
177
+ download_raw_file(manifest_url, str(temp_manifest), timeout=request_timeout)
178
+ with open(temp_manifest, "r", encoding="utf-8") as f:
179
+ manifest = json.load(f)
180
+ except Exception:
181
+ return {}
182
+ finally:
183
+ shutil.rmtree(temp_dir, ignore_errors=True)
184
+
185
+ if not isinstance(manifest, dict):
186
+ return {}
187
+ for record in manifest.values():
188
+ if isinstance(record, dict) and record.get("artifact") == artifact_name:
189
+ return record
190
+ return {}
191
+
192
+
193
+ def upload_to_github(
194
+ source_path: str,
195
+ name: str,
196
+ repo_path: str,
197
+ password: str | None = None,
198
+ vault_dir: str = "vault",
199
+ request_timeout: int = 60,
200
+ password_env_var: str = DEFAULT_PASSWORD_ENV_VAR,
201
+ security_level: str = "high",
202
+ commit_message: str | None = None,
203
+ replace_existing: bool = True,
204
+ force_upload: bool = False,
205
+ ) -> StoredArtifact:
206
+ if not repo_path:
207
+ raise ValueError("repo_path is required.")
208
+
209
+ name = _validate_name(name)
210
+ resolved_password = _resolve_password(password, password_env_var)
211
+ config = GitStoreConfig(password=resolved_password, request_timeout=request_timeout)
212
+ repo = Path(repo_path).expanduser().resolve()
213
+ if not (repo / ".git").exists():
214
+ raise FileNotFoundError(f"Not a git repository: {repo}")
215
+
216
+ vault = vault_dir.strip().strip("/\\") or "vault"
217
+ manifest_rel = f"{vault}/index.json"
218
+ manifest_path = repo / manifest_rel
219
+
220
+ source = Path(source_path).expanduser().resolve()
221
+ if not source.exists():
222
+ raise FileNotFoundError(f"Input path not found: {source}")
223
+
224
+ is_directory = source.is_dir()
225
+ source_hash = _hmac_source(source, config.password)
226
+ manifest = _load_manifest_local(manifest_path)
227
+ existing_record = manifest.get(name)
228
+ if existing_record:
229
+ existing_source_hash = str(existing_record.get("source_hash", "")).lower()
230
+ if existing_source_hash == source_hash.lower() and not force_upload:
231
+ print(
232
+ f"[gitstore] Skip upload: '{name}' already up to date "
233
+ f"(source_hash={source_hash[:24]})."
234
+ )
235
+ return StoredArtifact(
236
+ name=name,
237
+ artifact=str(existing_record["artifact"]),
238
+ artifact_hash=str(existing_record.get("artifact_hash", "")),
239
+ source_hash=existing_source_hash,
240
+ is_directory=bool(existing_record["is_directory"]),
241
+ created_at_utc=str(existing_record["created_at_utc"]),
242
+ )
243
+ if not replace_existing:
244
+ raise ValueError(f"Name '{name}' already exists. Use replace_existing=True to replace it.")
245
+
246
+ if is_directory:
247
+ encrypted_path = Path(
248
+ encrypt_directory(
249
+ source_directory=str(source),
250
+ config=config,
251
+ security_level=security_level,
252
+ )
253
+ )
254
+ else:
255
+ encrypted_path = Path(
256
+ encrypt_file(
257
+ source_path=str(source),
258
+ config=config,
259
+ security_level=security_level,
260
+ )
261
+ )
262
+
263
+ try:
264
+ extension = "".join(encrypted_path.suffixes) or ".asc"
265
+ artifact_hash = _hash_from_name_or_content(encrypted_path)
266
+ artifact_filename = f"{name}{extension}"
267
+ artifact_rel = f"{vault}/{artifact_filename}"
268
+ artifact_abs = repo / artifact_rel
269
+ artifact_abs.parent.mkdir(parents=True, exist_ok=True)
270
+
271
+ shutil.copy2(encrypted_path, artifact_abs)
272
+
273
+ record = StoredArtifact(
274
+ name=name,
275
+ artifact=artifact_filename,
276
+ artifact_hash=artifact_hash,
277
+ source_hash=source_hash,
278
+ is_directory=is_directory,
279
+ created_at_utc=datetime.now(timezone.utc).isoformat(),
280
+ )
281
+ manifest[name] = {
282
+ "name": record.name,
283
+ "artifact": record.artifact,
284
+ "artifact_hash": record.artifact_hash,
285
+ "source_hash": record.source_hash,
286
+ "is_directory": record.is_directory,
287
+ "created_at_utc": record.created_at_utc,
288
+ }
289
+ _save_manifest_local(manifest_path, manifest)
290
+
291
+ message = commit_message or f"gitstore: store '{name}'"
292
+ paths_to_commit = [artifact_rel, manifest_rel]
293
+ git_add_commit_push(
294
+ repo_path=str(repo),
295
+ paths_in_repo=paths_to_commit,
296
+ commit_message=message,
297
+ )
298
+ print(f"[gitstore] Uploaded '{name}' -> '{artifact_rel}' (hash={artifact_hash}).")
299
+ return record
300
+ finally:
301
+ if encrypted_path.exists():
302
+ encrypted_path.unlink()
303
+
304
+
305
+ def _load_manifest_local(manifest_path: Path) -> dict:
306
+ if not manifest_path.exists():
307
+ return {}
308
+ with open(manifest_path, "r", encoding="utf-8") as f:
309
+ data = json.load(f)
310
+ if not isinstance(data, dict):
311
+ raise ValueError("Manifest format is invalid.")
312
+ return data
313
+
314
+
315
+ def _save_manifest_local(manifest_path: Path, manifest: dict) -> None:
316
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
317
+ with open(manifest_path, "w", encoding="utf-8", newline="\n") as f:
318
+ json.dump(manifest, f, indent=2, sort_keys=False)
319
+ f.write("\n")
320
+
@@ -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
+ )