Skip to content

Commit 269771b

Browse files
authored
Return regular strings from dulwich.porcelain.status() (#2059)
2 parents 7a569e8 + 7a765b4 commit 269771b

File tree

6 files changed

+132
-53
lines changed

6 files changed

+132
-53
lines changed

NEWS

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
0.25.1 UNRELEASED
22

3+
* ``dulwich.porcelain.status`` now returns regular strings.
4+
(Jelmer Vernooij, #889)
5+
36
* Fix AssertionError when accessing ref names with length matching binary
47
hash length (e.g., 32 bytes for SHA-256). (Jelmer Vernooij, #2040)
58

dulwich/cli.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3311,25 +3311,23 @@ def run(self, args: Sequence[str]) -> None:
33113311
sys.stdout.write("Changes to be committed:\n\n")
33123312
for kind, names in status.staged.items():
33133313
for name in names:
3314-
sys.stdout.write(
3315-
f"\t{kind}: {name.decode(sys.getfilesystemencoding())}\n"
3316-
)
3314+
sys.stdout.write(f"\t{kind}: {os.fsdecode(name)}\n")
33173315
sys.stdout.write("\n")
33183316
if status.unstaged:
33193317
sys.stdout.write("Changes not staged for commit:\n\n")
33203318
for name in status.unstaged:
3321-
sys.stdout.write(f"\t{name.decode(sys.getfilesystemencoding())}\n")
3319+
sys.stdout.write(f"\t{os.fsdecode(name)}\n")
33223320
sys.stdout.write("\n")
33233321
if status.untracked:
33243322
sys.stdout.write("Untracked files:\n\n")
33253323
if parsed_args.column:
33263324
# Format untracked files in columns
3327-
untracked_names = [name for name in status.untracked]
3325+
untracked_names = [os.fsdecode(name) for name in status.untracked]
33283326
output = format_columns(untracked_names, mode="column", indent="\t")
33293327
sys.stdout.write(output)
33303328
else:
33313329
for name in status.untracked:
3332-
sys.stdout.write(f"\t{name}\n")
3330+
sys.stdout.write(f"\t{os.fsdecode(name)}\n")
33333331
sys.stdout.write("\n")
33343332

33353333

dulwich/porcelain/__init__.py

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,28 @@ def path_to_tree_path(
749749
return bytes(relpath)
750750

751751

752+
def tree_path_to_fs_path(
753+
tree_path: bytes,
754+
tree_encoding: str = DEFAULT_ENCODING,
755+
) -> bytes:
756+
"""Convert a git tree path to a filesystem path (relative).
757+
758+
Args:
759+
tree_path: Path from git tree (bytes with "/" separators, UTF-8 encoded)
760+
tree_encoding: Encoding used for tree paths (default: utf-8)
761+
Returns: Filesystem path as bytes (with os.sep, filesystem encoding)
762+
"""
763+
# Decode from tree encoding
764+
path_str = tree_path.decode(tree_encoding)
765+
766+
# Replace / with OS separator if needed
767+
if os.sep != "/":
768+
path_str = path_str.replace("/", os.sep)
769+
770+
# Encode for filesystem
771+
return os.fsencode(path_str)
772+
773+
752774
class DivergedBranches(Error):
753775
"""Branches have diverged and fast-forward is not possible."""
754776

@@ -2963,9 +2985,9 @@ def status(
29632985
directories that are entirely untracked without listing all their contents.
29642986
29652987
Returns: GitStatus tuple,
2966-
staged - dict with lists of staged paths (diff index/HEAD)
2967-
unstaged - list of unstaged paths (diff index/working-tree)
2968-
untracked - list of untracked, un-ignored & non-.git paths
2988+
staged - dict with lists of staged paths (filesystem paths as bytes)
2989+
unstaged - list of unstaged paths (filesystem paths as bytes)
2990+
untracked - list of untracked paths (filesystem paths as bytes)
29692991
"""
29702992
with open_repo_closing(repo) as r:
29712993
# Open the index once and reuse it for both staged and unstaged checks
@@ -2985,7 +3007,7 @@ def status(
29853007
config = r.get_config_stack()
29863008
preload_index = config.get_boolean(b"core", b"preloadIndex", False)
29873009

2988-
unstaged_changes = list(
3010+
unstaged_changes_tree = list(
29893011
get_unstaged_changes(index, r.path, filter_callback, preload_index)
29903012
)
29913013

@@ -2996,14 +3018,23 @@ def status(
29963018
exclude_ignored=not ignored,
29973019
untracked_files=untracked_files,
29983020
)
2999-
if sys.platform == "win32":
3000-
untracked_changes = [
3001-
path.replace(os.path.sep, "/") for path in untracked_paths
3021+
3022+
# Convert all paths to filesystem encoding
3023+
# Convert staged changes (dict with lists of tree paths)
3024+
staged_fs = {}
3025+
for change_type, paths in tracked_changes.items():
3026+
staged_fs[change_type] = [
3027+
tree_path_to_fs_path(p) if isinstance(p, bytes) else os.fsencode(p)
3028+
for p in paths
30023029
]
3003-
else:
3004-
untracked_changes = list(untracked_paths)
30053030

3006-
return GitStatus(tracked_changes, unstaged_changes, untracked_changes)
3031+
# Convert unstaged changes (list of tree paths)
3032+
unstaged_fs = [tree_path_to_fs_path(p) for p in unstaged_changes_tree]
3033+
3034+
# Convert untracked changes (list of strings)
3035+
untracked_fs = [os.fsencode(p) for p in untracked_paths]
3036+
3037+
return GitStatus(staged_fs, unstaged_fs, untracked_fs)
30073038

30083039

30093040
def shortlog(

tests/porcelain/__init__.py

Lines changed: 46 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4403,12 +4403,16 @@ def test_checkout_to_branch_with_untracked_files(self) -> None:
44034403
f.write("new message\n")
44044404

44054405
status = list(porcelain.status(self.repo))
4406-
self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["neu"]], status)
4406+
self.assertEqual(
4407+
[{"add": [], "delete": [], "modify": []}, [], [os.fsencode("neu")]], status
4408+
)
44074409

44084410
porcelain.checkout(self.repo, b"uni")
44094411

44104412
status = list(porcelain.status(self.repo))
4411-
self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["neu"]], status)
4413+
self.assertEqual(
4414+
[{"add": [], "delete": [], "modify": []}, [], [os.fsencode("neu")]], status
4415+
)
44124416

44134417
def test_checkout_to_branch_with_new_files(self) -> None:
44144418
porcelain.checkout(self.repo, b"uni")
@@ -6099,8 +6103,8 @@ def test_status_base(self) -> None:
60996103

61006104
results = porcelain.status(self.repo)
61016105

6102-
self.assertEqual(results.staged["add"][0], filename_add.encode("ascii"))
6103-
self.assertEqual(results.unstaged, [b"foo"])
6106+
self.assertEqual(results.staged["add"][0], os.fsencode(filename_add))
6107+
self.assertEqual(results.unstaged, [os.fsencode("foo")])
61046108

61056109
def test_status_with_core_preloadindex(self) -> None:
61066110
"""Test status with core.preloadIndex enabled."""
@@ -6139,7 +6143,7 @@ def test_status_with_core_preloadindex(self) -> None:
61396143

61406144
# Check that we detected the correct unstaged changes
61416145
unstaged_sorted = sorted(results.unstaged)
6142-
expected_sorted = sorted([f.encode("ascii") for f in modified_files])
6146+
expected_sorted = sorted([os.fsencode(f) for f in modified_files])
61436147
self.assertEqual(unstaged_sorted, expected_sorted)
61446148

61456149
def test_status_all(self) -> None:
@@ -6175,10 +6179,14 @@ def test_status_all(self) -> None:
61756179
f.write("origstuff")
61766180
results = porcelain.status(self.repo.path)
61776181
self.assertDictEqual(
6178-
{"add": [b"baz"], "delete": [b"foo"], "modify": [b"bar"]},
6182+
{
6183+
"add": [os.fsencode("baz")],
6184+
"delete": [os.fsencode("foo")],
6185+
"modify": [os.fsencode("bar")],
6186+
},
61796187
results.staged,
61806188
)
6181-
self.assertListEqual(results.unstaged, [b"blye"])
6189+
self.assertListEqual(results.unstaged, [os.fsencode("blye")])
61826190
results_no_untracked = porcelain.status(self.repo.path, untracked_files="no")
61836191
self.assertListEqual(results_no_untracked.untracked, [])
61846192

@@ -6194,7 +6202,9 @@ def test_status_untracked_path(self) -> None:
61946202
fh.write("untracked")
61956203

61966204
_, _, untracked = porcelain.status(self.repo.path, untracked_files="all")
6197-
self.assertEqual(untracked, ["untracked_dir/untracked_file"])
6205+
self.assertEqual(
6206+
untracked, [os.fsencode(os.path.join("untracked_dir", "untracked_file"))]
6207+
)
61986208

61996209
def test_status_untracked_path_normal(self) -> None:
62006210
# Create an untracked directory with multiple files
@@ -6216,16 +6226,16 @@ def test_status_untracked_path_normal(self) -> None:
62166226

62176227
# Test "normal" mode - should only show the directory, not individual files
62186228
_, _, untracked = porcelain.status(self.repo.path, untracked_files="normal")
6219-
self.assertEqual(untracked, ["untracked_dir/"])
6229+
self.assertEqual(untracked, [os.fsencode("untracked_dir" + os.sep)])
62206230

62216231
# Test "all" mode - should show all files
62226232
_, _, untracked_all = porcelain.status(self.repo.path, untracked_files="all")
62236233
self.assertEqual(
62246234
sorted(untracked_all),
62256235
[
6226-
"untracked_dir/file1",
6227-
"untracked_dir/file2",
6228-
"untracked_dir/nested/file3",
6236+
os.fsencode(os.path.join("untracked_dir", "file1")),
6237+
os.fsencode(os.path.join("untracked_dir", "file2")),
6238+
os.fsencode(os.path.join("untracked_dir", "nested", "file3")),
62296239
],
62306240
)
62316241

@@ -6253,11 +6263,15 @@ def test_status_mixed_tracked_untracked(self) -> None:
62536263

62546264
# In "normal" mode, should show individual untracked files in mixed dirs
62556265
_, _, untracked = porcelain.status(self.repo.path, untracked_files="normal")
6256-
self.assertEqual(untracked, ["mixed_dir/untracked.txt"])
6266+
self.assertEqual(
6267+
untracked, [os.fsencode(os.path.join("mixed_dir", "untracked.txt"))]
6268+
)
62576269

62586270
# In "all" mode, should be the same for mixed directories
62596271
_, _, untracked_all = porcelain.status(self.repo.path, untracked_files="all")
6260-
self.assertEqual(untracked_all, ["mixed_dir/untracked.txt"])
6272+
self.assertEqual(
6273+
untracked_all, [os.fsencode(os.path.join("mixed_dir", "untracked.txt"))]
6274+
)
62616275

62626276
def test_status_crlf_mismatch(self) -> None:
62636277
# First make a commit as if the file has been added on a Linux system
@@ -6280,7 +6294,7 @@ def test_status_crlf_mismatch(self) -> None:
62806294

62816295
results = porcelain.status(self.repo)
62826296
self.assertDictEqual({"add": [], "delete": [], "modify": []}, results.staged)
6283-
self.assertListEqual(results.unstaged, [b"crlf"])
6297+
self.assertListEqual(results.unstaged, [os.fsencode("crlf")])
62846298
self.assertListEqual(results.untracked, [])
62856299

62866300
def test_status_autocrlf_true(self) -> None:
@@ -6348,7 +6362,8 @@ def test_status_autocrlf_input(self) -> None:
63486362

63496363
results = porcelain.status(self.repo)
63506364
self.assertDictEqual(
6351-
{"add": [b"crlf-new"], "delete": [], "modify": []}, results.staged
6365+
{"add": [os.fsencode("crlf-new")], "delete": [], "modify": []},
6366+
results.staged,
63526367
)
63536368
# File committed with CRLF before autocrlf=input was enabled
63546369
# will NOT appear as unstaged because stat matching optimization
@@ -6382,7 +6397,7 @@ def test_status_autocrlf_input_modified(self) -> None:
63826397

63836398
results = porcelain.status(self.repo)
63846399
# Modified file should be detected as unstaged
6385-
self.assertListEqual(results.unstaged, [b"crlf-file.txt"])
6400+
self.assertListEqual(results.unstaged, [os.fsencode("crlf-file.txt")])
63866401

63876402
def test_status_autocrlf_input_binary(self) -> None:
63886403
"""Test that binary files are not affected by autocrlf=input."""
@@ -6554,11 +6569,16 @@ def test_get_untracked_paths(self) -> None:
65546569
),
65556570
)
65566571
self.assertEqual(
6557-
{".gitignore", "notignored", "link"},
6572+
{os.fsencode(".gitignore"), os.fsencode("notignored"), os.fsencode("link")},
65586573
set(porcelain.status(self.repo).untracked),
65596574
)
65606575
self.assertEqual(
6561-
{".gitignore", "notignored", "ignored", "link"},
6576+
{
6577+
os.fsencode(".gitignore"),
6578+
os.fsencode("notignored"),
6579+
os.fsencode("ignored"),
6580+
os.fsencode("link"),
6581+
},
65626582
set(porcelain.status(self.repo, ignored=True).untracked),
65636583
)
65646584

@@ -6679,14 +6699,16 @@ def test_get_untracked_paths_nested_gitignore(self) -> None:
66796699

66806700
# Test status() which uses exclude_ignored=True by default
66816701
status = porcelain.status(self.repo)
6682-
self.assertEqual(["untracked.txt"], status.untracked)
6702+
self.assertEqual([os.fsencode("untracked.txt")], status.untracked)
66836703

66846704
# Test status() with ignored=True which uses exclude_ignored=False
66856705
status_with_ignored = porcelain.status(self.repo, ignored=True)
66866706
# Should include cache directories
6687-
self.assertIn("untracked.txt", status_with_ignored.untracked)
6707+
self.assertIn(os.fsencode("untracked.txt"), status_with_ignored.untracked)
66886708
for cache_dir in cache_dirs:
6689-
self.assertIn(cache_dir + "/", status_with_ignored.untracked)
6709+
self.assertIn(
6710+
os.fsencode(cache_dir + os.sep), status_with_ignored.untracked
6711+
)
66906712

66916713
def test_get_untracked_paths_mixed_directory(self) -> None:
66926714
"""Test directory with both ignored and non-ignored files."""
@@ -6932,7 +6954,7 @@ def test_get_untracked_paths_normal(self) -> None:
69326954
_, _, untracked = porcelain.status(
69336955
repo=self.repo.path, untracked_files="normal"
69346956
)
6935-
self.assertEqual(untracked, ["untracked_dir/"])
6957+
self.assertEqual(untracked, [os.fsencode("untracked_dir" + os.sep)])
69366958

69376959
def test_get_untracked_paths_top_level_issue_1247(self) -> None:
69386960
"""Test for issue #1247: ensure top-level untracked files are detected."""
@@ -6955,7 +6977,7 @@ def test_get_untracked_paths_top_level_issue_1247(self) -> None:
69556977
# Test via status
69566978
status = porcelain.status(self.repo)
69576979
self.assertIn(
6958-
"sample.txt",
6980+
os.fsencode("sample.txt"),
69596981
status.untracked,
69606982
"Top-level file 'sample.txt' should be in status.untracked",
69616983
)

tests/test_repository.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1717,7 +1717,12 @@ def test_unstage_midify_file_with_dir(self) -> None:
17171717
self._repo.get_worktree().unstage(["new_dir/foo"])
17181718
status = list(porcelain.status(self._repo))
17191719
self.assertEqual(
1720-
[{"add": [], "delete": [], "modify": []}, [b"new_dir/foo"], []], status
1720+
[
1721+
{"add": [], "delete": [], "modify": []},
1722+
[os.fsencode(os.path.join("new_dir", "foo"))],
1723+
[],
1724+
],
1725+
status,
17211726
)
17221727

17231728
def test_unstage_while_no_commit(self) -> None:
@@ -1728,7 +1733,9 @@ def test_unstage_while_no_commit(self) -> None:
17281733
porcelain.add(self._repo, paths=[full_path])
17291734
self._repo.get_worktree().unstage([file])
17301735
status = list(porcelain.status(self._repo))
1731-
self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["foo"]], status)
1736+
self.assertEqual(
1737+
[{"add": [], "delete": [], "modify": []}, [], [os.fsencode("foo")]], status
1738+
)
17321739

17331740
def test_unstage_add_file(self) -> None:
17341741
file = "foo"
@@ -1744,7 +1751,9 @@ def test_unstage_add_file(self) -> None:
17441751
porcelain.add(self._repo, paths=[full_path])
17451752
self._repo.get_worktree().unstage([file])
17461753
status = list(porcelain.status(self._repo))
1747-
self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["foo"]], status)
1754+
self.assertEqual(
1755+
[{"add": [], "delete": [], "modify": []}, [], [os.fsencode("foo")]], status
1756+
)
17481757

17491758
def test_unstage_modify_file(self) -> None:
17501759
file = "foo"
@@ -1765,7 +1774,7 @@ def test_unstage_modify_file(self) -> None:
17651774
status = list(porcelain.status(self._repo))
17661775

17671776
self.assertEqual(
1768-
[{"add": [], "delete": [], "modify": []}, [b"foo"], []], status
1777+
[{"add": [], "delete": [], "modify": []}, [os.fsencode("foo")], []], status
17691778
)
17701779

17711780
def test_unstage_remove_file(self) -> None:
@@ -1784,7 +1793,7 @@ def test_unstage_remove_file(self) -> None:
17841793
self._repo.get_worktree().unstage([file])
17851794
status = list(porcelain.status(self._repo))
17861795
self.assertEqual(
1787-
[{"add": [], "delete": [], "modify": []}, [b"foo"], []], status
1796+
[{"add": [], "delete": [], "modify": []}, [os.fsencode("foo")], []], status
17881797
)
17891798

17901799
def test_reset_index(self) -> None:
@@ -1796,11 +1805,18 @@ def test_reset_index(self) -> None:
17961805
r.get_worktree().stage(["a", "b"])
17971806
status = list(porcelain.status(self._repo))
17981807
self.assertEqual(
1799-
[{"add": [b"b"], "delete": [], "modify": [b"a"]}, [], []], status
1808+
[
1809+
{"add": [os.fsencode("b")], "delete": [], "modify": [os.fsencode("a")]},
1810+
[],
1811+
[],
1812+
],
1813+
status,
18001814
)
18011815
r.get_worktree().reset_index()
18021816
status = list(porcelain.status(self._repo))
1803-
self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["b"]], status)
1817+
self.assertEqual(
1818+
[{"add": [], "delete": [], "modify": []}, [], [os.fsencode("b")]], status
1819+
)
18041820

18051821
@skipIf(
18061822
sys.platform in ("win32", "darwin"),

0 commit comments

Comments
 (0)