Repository: borgbackup/borg

Issue:
No dry-run flags when using borg extract 
Using borg 1.4, I checked the use of "flags" when doing a dry-run for different borg commands. 

The command `borg extract --dry-run --list` does not print the flags (that are used with e.g. borg create / borg recreate
Instead, `borg extract` prints only the list of items that would be included in the extract without the --dry-run. 

This was the command, I used: 
`borg extract REPO::ARCHIVE /home/user/test Admin -e 'home/user/test/*.bak'  --dry-run --verbose --list `

In the name of consistency, dry-run flags could be added to borg extract. 



Discussion:
ThomasWaldmann: BTW, don't add `coverage.*` files to the repo, these are temporary files.
alighazi288: Got it, I’ll ensure temporary files are omitted moving forward. Could you please share more details or context about the errors above? This would help me better understand and address the issue.

ThomasWaldmann: The failing `test_return_codes` test is because `borg extract` is used there and expected to finish with rc 0, but it crashes and returns rc 2.
alighazi288: Changed the code and now everything seems to be running as expected at my end and **borg extract** finishes with rc 0. Just wanted to ask if the errors could be because of me working on MacOS and not having pyfuse3?
ThomasWaldmann: The errors are unrelated to macOS and not having pyfuse3 - I also work on a macbook.

If one want to test or work on the mount / FUSE code in borg, one would need macFUSE installed and use llfuse (not pyfuse3). But this is not required here.
alighazi288: @ThomasWaldmann Please review.
ThomasWaldmann: Can you rather rebase on master please than merge master in here?
alighazi288: I wanted to let you know that there appears to be a large number of changes in this pull request because I encountered some issues while attempting to rebase on master. I apologize for any confusion this may have caused.
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/borgbackup/borg/pull/8575?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup) Report
Attention: Patch coverage is `92.59259%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 81.39%. Comparing base [(`694fa93`)](https://app.codecov.io/gh/borgbackup/borg/commit/694fa93b761a11f5a3f9b5a9e8227eabea2cb9e1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup) to head [(`8404970`)](https://app.codecov.io/gh/borgbackup/borg/commit/8404970c5324c47bee6e804bef76140f4bf31f63?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup).
> Report is 4 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/borgbackup/borg/pull/8575?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup) | Patch % | Lines |
|---|---|---|
| [src/borg/archiver/extract\_cmd.py](https://app.codecov.io/gh/borgbackup/borg/pull/8575?src=pr&el=tree&filepath=src%2Fborg%2Farchiver%2Fextract_cmd.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup#diff-c3JjL2JvcmcvYXJjaGl2ZXIvZXh0cmFjdF9jbWQucHk=) | 92.59% | [2 Missing :warning: ](https://app.codecov.io/gh/borgbackup/borg/pull/8575?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #8575      +/-   ##
==========================================
- Coverage   81.84%   81.39%   -0.45%     
==========================================
  Files          74       74              
  Lines       13311    13292      -19     
  Branches     1960     1959       -1     
==========================================
- Hits        10894    10819      -75     
- Misses       1755     1801      +46     
- Partials      662      672      +10     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/borgbackup/borg/pull/8575?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup).

ThomasWaldmann: There is still that ".coverage 2" file in your PR, you need to remove it, it is a temporary file and must not be committed.

Patch:
diff --git a/src/borg/archiver/extract_cmd.py b/src/borg/archiver/extract_cmd.py
index d0fafc1d..c7fc4b08 100644
--- a/src/borg/archiver/extract_cmd.py
+++ b/src/borg/archiver/extract_cmd.py
@@ -56,33 +56,50 @@ def do_extract(self, args, repository, manifest, archive):
         else:
             pi = None
 
-        for item in archive.iter_items(filter):
-            archive.preload_item_chunks(item, optimize_hardlinks=True)
+        for item in archive.iter_items():
             orig_path = item.path
             if strip_components:
-                item.path = os.sep.join(orig_path.split(os.sep)[strip_components:])
-            if not args.dry_run:
-                while dirs and not item.path.startswith(dirs[-1].path):
-                    dir_item = dirs.pop(-1)
-                    try:
-                        archive.extract_item(dir_item, stdout=stdout)
-                    except BackupError as e:
-                        self.print_warning_instance(BackupWarning(remove_surrogates(dir_item.path), e))
+                stripped_path = os.sep.join(orig_path.split(os.sep)[strip_components:])
+                if not stripped_path:
+                    continue
+                item.path = stripped_path
+
+            is_matched = matcher.match(orig_path)
+
             if output_list:
-                logging.getLogger("borg.output.list").info(remove_surrogates(item.path))
-            try:
-                if dry_run:
-                    archive.extract_item(item, dry_run=True, hlm=hlm, pi=pi)
-                else:
-                    if stat.S_ISDIR(item.mode):
-                        dirs.append(item)
-                        archive.extract_item(item, stdout=stdout, restore_attrs=False)
+                log_prefix = "+" if is_matched else "-"
+                logging.getLogger("borg.output.list").info(f"{log_prefix} {remove_surrogates(item.path)}")
+
+            if is_matched:
+                archive.preload_item_chunks(item, optimize_hardlinks=True)
+
+                if not dry_run:
+                    while dirs and not item.path.startswith(dirs[-1].path):
+                        dir_item = dirs.pop(-1)
+                        try:
+                            archive.extract_item(dir_item, stdout=stdout)
+                        except BackupError as e:
+                            self.print_warning_instance(BackupWarning(remove_surrogates(dir_item.path), e))
+
+                try:
+                    if dry_run:
+                        archive.extract_item(item, dry_run=True, hlm=hlm, pi=pi)
                     else:
-                        archive.extract_item(
-                            item, stdout=stdout, sparse=sparse, hlm=hlm, pi=pi, continue_extraction=continue_extraction
-                        )
-            except BackupError as e:
-                self.print_warning_instance(BackupWarning(remove_surrogates(orig_path), e))
+                        if stat.S_ISDIR(item.mode):
+                            dirs.append(item)
+                            archive.extract_item(item, stdout=stdout, restore_attrs=False)
+                        else:
+                            archive.extract_item(
+                                item,
+                                stdout=stdout,
+                                sparse=sparse,
+                                hlm=hlm,
+                                pi=pi,
+                                continue_extraction=continue_extraction,
+                            )
+                except BackupError as e:
+                    self.print_warning_instance(BackupWarning(remove_surrogates(orig_path), e))
+
         if pi:
             pi.finish()
 


Test patch:
diff --git a/src/borg/testsuite/archiver/extract_cmd_test.py b/src/borg/testsuite/archiver/extract_cmd_test.py
index b4f8d317..a6dd9632 100644
--- a/src/borg/testsuite/archiver/extract_cmd_test.py
+++ b/src/borg/testsuite/archiver/extract_cmd_test.py
@@ -718,3 +718,22 @@ def test_extract_continue(archivers, request):
             assert f.read() == CONTENTS2
         with open("input/file3", "rb") as f:
             assert f.read() == CONTENTS3
+
+
+def test_dry_run_extraction_flags(archivers, request):
+    archiver = request.getfixturevalue(archivers)
+    cmd(archiver, "repo-create", RK_ENCRYPTION)
+    create_regular_file(archiver.input_path, "file1", 0)
+    create_regular_file(archiver.input_path, "file2", 0)
+    create_regular_file(archiver.input_path, "file3", 0)
+    cmd(archiver, "create", "test", "input")
+
+    output = cmd(archiver, "extract", "--dry-run", "--list", "test", "-e", "input/file3")
+
+    expected_output = ["+ input/file1", "+ input/file2", "- input/file3"]
+    output_lines = output.splitlines()
+    for expected in expected_output:
+        assert expected in output_lines, f"Expected line not found: {expected}"
+        print(output)
+
+    assert not os.listdir("output"), "Output directory should be empty after dry-run"