fluidattacks_core_git 12.0.0__tar.gz → 12.1.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.
Files changed (30) hide show
  1. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/.gitignore +2 -0
  2. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/PKG-INFO +1 -1
  3. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/fluidattacks_core/git/download_repo.py +27 -7
  4. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/pyproject.toml +2 -1
  5. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/tests/test_git.py +76 -1
  6. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/uv.lock +16 -1
  7. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/.envrc +0 -0
  8. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/build/default.nix +0 -0
  9. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/build/filter.nix +0 -0
  10. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/flake.lock +0 -0
  11. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/flake.nix +0 -0
  12. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/fluidattacks_core/git/__init__.py +0 -0
  13. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/fluidattacks_core/git/classes.py +0 -0
  14. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/fluidattacks_core/git/clone.py +0 -0
  15. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/fluidattacks_core/git/codecommit_utils.py +0 -0
  16. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/fluidattacks_core/git/constants.py +0 -0
  17. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/fluidattacks_core/git/delete_files.py +0 -0
  18. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/fluidattacks_core/git/download_file.py +0 -0
  19. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/fluidattacks_core/git/https_utils.py +0 -0
  20. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/fluidattacks_core/git/py.typed +0 -0
  21. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/fluidattacks_core/git/remote.py +0 -0
  22. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/fluidattacks_core/git/show_file.py +0 -0
  23. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/fluidattacks_core/git/ssh_utils.py +0 -0
  24. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/fluidattacks_core/git/upload_repo.py +0 -0
  25. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/fluidattacks_core/git/utils.py +0 -0
  26. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/fluidattacks_core/git/warp.py +0 -0
  27. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/import-linter.cfg +0 -0
  28. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/mypy.ini +0 -0
  29. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/ruff.toml +0 -0
  30. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.0}/tests/__init__.py +0 -0
@@ -1,3 +1,5 @@
1
+ dist-*/
2
+ /.cargo-cache
1
3
  target/
2
4
  .direnv/
3
5
  .venv/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fluidattacks_core_git
3
- Version: 12.0.0
3
+ Version: 12.1.0
4
4
  Summary: Fluid Attacks Core Git Library
5
5
  Author-email: Development <development@fluidattacks.com>
6
6
  License: MPL-2.0
@@ -46,13 +46,7 @@ def remove_symlinks_in_directory(directory: str) -> None:
46
46
  Path(file_path).unlink(missing_ok=True)
47
47
 
48
48
 
49
- def _reset_repo_sync(repo_path: str) -> bool:
50
- try:
51
- Path.cwd()
52
- except OSError:
53
- LOGGER.exception("Failed to get the working directory: %s", repo_path)
54
- os.chdir(repo_path)
55
-
49
+ def _mark_safe_directory(repo_path: str) -> None:
56
50
  try:
57
51
  Git().execute(
58
52
  [
@@ -67,6 +61,16 @@ def _reset_repo_sync(repo_path: str) -> bool:
67
61
  except GitError:
68
62
  LOGGER.exception("Failed to add safe directory %s", repo_path)
69
63
 
64
+
65
+ def _reset_repo_sync(repo_path: str) -> bool:
66
+ try:
67
+ Path.cwd()
68
+ except OSError:
69
+ LOGGER.exception("Failed to get the working directory: %s", repo_path)
70
+ os.chdir(repo_path)
71
+
72
+ _mark_safe_directory(repo_path)
73
+
70
74
  try:
71
75
  repo = Repo(repo_path)
72
76
  repo.git.reset("--hard", "HEAD")
@@ -114,7 +118,16 @@ async def download_repo_from_s3(
114
118
  git_ignore: list[str] | None = None,
115
119
  *,
116
120
  download_buffer_size: int = DEFAULT_DOWNLOAD_BUFFER_SIZE,
121
+ checkout: bool = True,
117
122
  ) -> bool:
123
+ """Download the repository archived in s3 at `download_url`.
124
+
125
+ The archive holds only `.git`, so the working tree is restored with a
126
+ `git reset --hard`. Pass `checkout=False` to keep just the history: peak
127
+ disk stays at the size of the archive plus `.git`, which is what a
128
+ consumer that walks commits needs. `git_ignore` prunes working-tree files
129
+ and has no effect on a history-only download.
130
+ """
118
131
  await anyio.Path(destination_path.parent).mkdir(parents=True, exist_ok=True)
119
132
  with tempfile.TemporaryDirectory(prefix="fluidattacks_", ignore_cleanup_errors=True) as tmpdir:
120
133
  tmp_path = Path(tmpdir)
@@ -139,6 +152,13 @@ async def download_repo_from_s3(
139
152
  )
140
153
  return False
141
154
 
155
+ if not checkout:
156
+ # Nothing was extracted but `.git`, so there is no working tree to
157
+ # reset or prune. Callers still read the repository through git, which
158
+ # refuses a directory it considers foreign.
159
+ await anyio.to_thread.run_sync(_mark_safe_directory, str(destination_path))
160
+ return True
161
+
142
162
  if not await reset_repo(str(destination_path)):
143
163
  shutil.rmtree(destination_path, ignore_errors=True)
144
164
  return False
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fluidattacks_core_git"
3
- version = "12.0.0"
3
+ version = "12.1.0"
4
4
  description = "Fluid Attacks Core Git Library"
5
5
  authors = [{ name = "Development", email = "development@fluidattacks.com" }]
6
6
  license = { text = "MPL-2.0" }
@@ -32,6 +32,7 @@ dev = [
32
32
  "deptry>=0.23.1",
33
33
  "import-linter==2.11",
34
34
  "mypy==1.19.1",
35
+ "pytest-asyncio>=1.1.0",
35
36
  "pytest-cov>=7.0.0",
36
37
  "pytest==9.0.3",
37
38
  "ruff==0.15.0",
@@ -1,3 +1,5 @@
1
+ import shutil
2
+ import tarfile
1
3
  import tempfile
2
4
  from pathlib import Path
3
5
 
@@ -6,7 +8,7 @@ from git.repo import Repo
6
8
  from pathspec import PathSpec
7
9
  from pathspec import util as pathspec_util
8
10
 
9
- from fluidattacks_core.git import rebase
11
+ from fluidattacks_core.git import download_repo, download_repo_from_s3, rebase
10
12
  from fluidattacks_core.git.delete_files import delete_out_of_scope_files
11
13
  from fluidattacks_core.git.download_repo import remove_symlinks_in_directory
12
14
 
@@ -171,3 +173,76 @@ def test_rebase_preserves_ascii_renamed_path() -> None:
171
173
  assert result.path == renamed
172
174
  assert result.line == 1
173
175
  assert result.rev == rev_b
176
+
177
+
178
+ def _archive_git_dir(repo_path: Path, archive_path: Path, name: str) -> None:
179
+ # Mimics `_create_tar_archive`: the archive uploaded to s3 carries `.git`
180
+ # and no tracked file.
181
+ with tarfile.open(archive_path, "w:gz") as tar_handler:
182
+ tar_handler.add(str(repo_path / ".git"), arcname=f"{name}/.git", recursive=True)
183
+
184
+
185
+ def _committed_repo(path: Path, tracked: str) -> str:
186
+ repo = _init_repo(path)
187
+ (path / tracked).write_text("content\n", encoding="utf-8")
188
+ repo.index.add([tracked])
189
+ revision = repo.index.commit("add tracked file").hexsha
190
+ repo.close()
191
+ return revision
192
+
193
+
194
+ @pytest.fixture
195
+ def s3_archive(
196
+ tmp_path: Path,
197
+ monkeypatch: pytest.MonkeyPatch,
198
+ ) -> str:
199
+ # `git config --global` writes to $HOME, so keep it inside the test.
200
+ monkeypatch.setenv("HOME", str(tmp_path / "home"))
201
+ (tmp_path / "home").mkdir()
202
+
203
+ source = tmp_path / "source"
204
+ source.mkdir()
205
+ revision = _committed_repo(source, "tracked.txt")
206
+ archive = tmp_path / "repo.tar.gz"
207
+ _archive_git_dir(source, archive, "source")
208
+ shutil.rmtree(source)
209
+
210
+ async def _fake_download(
211
+ *,
212
+ url: str, # noqa: ARG001
213
+ destination_path: str,
214
+ download_buffer_size: int, # noqa: ARG001
215
+ ) -> bool:
216
+ shutil.copyfile(archive, destination_path)
217
+ return True
218
+
219
+ monkeypatch.setattr(download_repo, "download_file", _fake_download)
220
+ return revision
221
+
222
+
223
+ @pytest.mark.asyncio
224
+ @pytest.mark.usefixtures("s3_archive")
225
+ async def test_download_repo_from_s3_restores_working_tree(tmp_path: Path) -> None:
226
+ destination = tmp_path / "groups" / "group" / "root"
227
+
228
+ assert await download_repo_from_s3("https://s3/repo.tar.gz", destination)
229
+
230
+ assert (destination / "tracked.txt").is_file()
231
+
232
+
233
+ @pytest.mark.asyncio
234
+ async def test_download_repo_from_s3_without_checkout_keeps_only_history(
235
+ tmp_path: Path,
236
+ s3_archive: str,
237
+ ) -> None:
238
+ destination = tmp_path / "groups" / "group" / "root"
239
+
240
+ assert await download_repo_from_s3("https://s3/repo.tar.gz", destination, checkout=False)
241
+
242
+ # The point of the flag: the history is complete and readable, and not one
243
+ # byte of the working tree was written.
244
+ repo = Repo(destination)
245
+ assert repo.head.commit.hexsha == s3_archive
246
+ repo.close()
247
+ assert not (destination / "tracked.txt").exists()
248
+ assert [entry.name for entry in destination.iterdir()] == [".git"]
@@ -432,7 +432,7 @@ wheels = [
432
432
 
433
433
  [[package]]
434
434
  name = "fluidattacks-core-git"
435
- version = "12.0.0"
435
+ version = "12.1.0"
436
436
  source = { editable = "." }
437
437
  dependencies = [
438
438
  { name = "aiofiles" },
@@ -454,6 +454,7 @@ dev = [
454
454
  { name = "import-linter" },
455
455
  { name = "mypy" },
456
456
  { name = "pytest" },
457
+ { name = "pytest-asyncio" },
457
458
  { name = "pytest-cov" },
458
459
  { name = "ruff" },
459
460
  { name = "types-aiofiles" },
@@ -480,6 +481,7 @@ dev = [
480
481
  { name = "import-linter", specifier = "==2.11" },
481
482
  { name = "mypy", specifier = "==1.19.1" },
482
483
  { name = "pytest", specifier = "==9.0.3" },
484
+ { name = "pytest-asyncio", specifier = ">=1.1.0" },
483
485
  { name = "pytest-cov", specifier = ">=7.0.0" },
484
486
  { name = "ruff", specifier = "==0.15.0" },
485
487
  { name = "types-aiofiles", specifier = "==24.1.0.20240626" },
@@ -1331,6 +1333,19 @@ wheels = [
1331
1333
  { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
1332
1334
  ]
1333
1335
 
1336
+ [[package]]
1337
+ name = "pytest-asyncio"
1338
+ version = "1.4.0"
1339
+ source = { registry = "https://pypi.org/simple" }
1340
+ dependencies = [
1341
+ { name = "pytest" },
1342
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
1343
+ ]
1344
+ sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
1345
+ wheels = [
1346
+ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
1347
+ ]
1348
+
1334
1349
  [[package]]
1335
1350
  name = "pytest-cov"
1336
1351
  version = "7.1.0"