Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
0.23.1 UNRELEASED

* status: Return untracked paths as bytestrings, similar to other
paths. (Jelmer Vernooij, #889)

* Support short commit hashes in ``porcelain.reset()``.
(Jelmer Vernooij, #1154)

Expand Down
21 changes: 16 additions & 5 deletions dulwich/porcelain.py
Original file line number Diff line number Diff line change
Expand Up @@ -672,11 +672,22 @@ def add(repo: Union[str, os.PathLike, BaseRepo] = ".", paths=None):
)
)
for untracked_path in current_untracked:
# Convert bytes to string if needed
if isinstance(untracked_path, bytes):
untracked_path_str = untracked_path.decode("utf-8")
else:
untracked_path_str = untracked_path

# If we're scanning a subdirectory, adjust the path
if relpath != ".":
untracked_path = posixpath.join(relpath, untracked_path)
untracked_path_str = posixpath.join(relpath, untracked_path_str)
untracked_path = (
untracked_path_str.encode("utf-8")
if isinstance(untracked_path, bytes)
else untracked_path_str
)

if not ignore_manager.is_ignored(untracked_path):
if not ignore_manager.is_ignored(untracked_path_str):
relpaths.append(untracked_path)
else:
ignored.add(untracked_path)
Expand Down Expand Up @@ -1678,7 +1689,7 @@ def status(repo=".", ignored=False, untracked_files="all"):
)
if sys.platform == "win32":
untracked_changes = [
path.replace(os.path.sep, "/") for path in untracked_paths
path.replace(os.fsencode(os.path.sep), b"/") for path in untracked_paths
]
else:
untracked_changes = list(untracked_paths)
Expand Down Expand Up @@ -1758,7 +1769,7 @@ def prune_dirnames(dirpath, dirnames):
if ignore_manager.is_ignored(ip):
if not exclude_ignored:
ignored_dirs.append(
os.path.join(os.path.relpath(path, frompath), "")
os.fsencode(os.path.join(os.path.relpath(path, frompath), ""))
)
del dirnames[i]
return dirnames
Expand All @@ -1772,7 +1783,7 @@ def prune_dirnames(dirpath, dirnames):
if not exclude_ignored or not ignore_manager.is_ignored(
os.path.relpath(ap, basepath)
):
yield os.path.relpath(ap, frompath)
yield os.fsencode(os.path.relpath(ap, frompath))

yield from ignored_dirs

Expand Down
83 changes: 52 additions & 31 deletions tests/test_porcelain.py
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,18 @@ def test_init_bare_pathlib(self) -> None:


class AddTests(PorcelainTestCase):
def _normalize_paths(self, paths):
"""Normalize paths returned by porcelain.add() for comparison.

Handles both bytes and strings, converts to strings with forward slashes.
"""
normalized = []
for path in paths:
if isinstance(path, bytes):
path = path.decode("utf-8")
normalized.append(path.replace(os.sep, "/"))
return normalized

def test_add_default_paths(self) -> None:
# create a file for initial commit
fullpath = os.path.join(self.repo.path, "blah")
Expand All @@ -969,7 +981,7 @@ def test_add_default_paths(self) -> None:
self.assertEqual({"foo", "blah", "adir", ".git"}, set(os.listdir(".")))
added, ignored = porcelain.add(self.repo.path)
# Normalize paths to use forward slashes for comparison
added_normalized = [path.replace(os.sep, "/") for path in added]
added_normalized = self._normalize_paths(added)
self.assertEqual(
(added_normalized, ignored),
(["foo", "adir/afile"], set()),
Expand Down Expand Up @@ -1140,8 +1152,10 @@ def test_add_symlink_to_directory_inside_repo(self) -> None:

# When adding a symlink to a directory, it follows the symlink and adds contents
self.assertEqual(len(added), 2)
self.assertIn("link_to_dir/file1.txt", added)
self.assertIn("link_to_dir/file2.txt", added)
# Convert bytes to strings for comparison
added_normalized = self._normalize_paths(added)
self.assertIn("link_to_dir/file1.txt", added_normalized)
self.assertIn("link_to_dir/file2.txt", added_normalized)
self.assertEqual(len(ignored), 0)

# Verify files are added through the symlink path
Expand Down Expand Up @@ -1283,9 +1297,10 @@ def test_add_repo_path(self) -> None:
added, ignored = porcelain.add(self.repo.path, paths=[self.repo.path])

# Should add all untracked files, not stage './'
self.assertIn("file1.txt", added)
self.assertIn("file2.txt", added)
self.assertNotIn("./", added)
added_normalized = self._normalize_paths(added)
self.assertIn("file1.txt", added_normalized)
self.assertIn("file2.txt", added_normalized)
self.assertNotIn("./", added_normalized)

# Verify files are actually staged
index = self.repo.open_index()
Expand All @@ -1310,7 +1325,7 @@ def test_add_directory_contents(self) -> None:
# Should add all files in the directory
self.assertEqual(len(added), 3)
# Normalize paths to use forward slashes for comparison
added_normalized = [path.replace(os.sep, "/") for path in added]
added_normalized = self._normalize_paths(added)
self.assertIn("subdir/file1.txt", added_normalized)
self.assertIn("subdir/file2.txt", added_normalized)
self.assertIn("subdir/file3.txt", added_normalized)
Expand Down Expand Up @@ -1343,7 +1358,7 @@ def test_add_nested_directories(self) -> None:
# Should add all files recursively
self.assertEqual(len(added), 3)
# Normalize paths to use forward slashes for comparison
added_normalized = [path.replace(os.sep, "/") for path in added]
added_normalized = self._normalize_paths(added)
self.assertIn("dir1/file1.txt", added_normalized)
self.assertIn("dir1/dir2/file2.txt", added_normalized)
self.assertIn("dir1/dir2/dir3/file3.txt", added_normalized)
Expand Down Expand Up @@ -1384,7 +1399,7 @@ def test_add_directory_with_tracked_files(self) -> None:
# Should only add the untracked files
self.assertEqual(len(added), 2)
# Normalize paths to use forward slashes for comparison
added_normalized = [path.replace(os.sep, "/") for path in added]
added_normalized = self._normalize_paths(added)
self.assertIn("mixed/untracked1.txt", added_normalized)
self.assertIn("mixed/untracked2.txt", added_normalized)
self.assertNotIn("mixed/tracked.txt", added)
Expand Down Expand Up @@ -1426,14 +1441,14 @@ def test_add_directory_with_gitignore(self) -> None:

# Should only add non-ignored files
# Normalize paths to use forward slashes for comparison
added_normalized = {path.replace(os.sep, "/") for path in added}
added_normalized = set(self._normalize_paths(added))
self.assertEqual(
added_normalized, {"testdir/important.txt", "testdir/readme.md"}
)

# Check ignored files
# Normalize paths to use forward slashes for comparison
ignored_normalized = {path.replace(os.sep, "/") for path in ignored}
ignored_normalized = set(self._normalize_paths(ignored))
self.assertIn("testdir/debug.log", ignored_normalized)
self.assertIn("testdir/temp.tmp", ignored_normalized)
self.assertIn("testdir/build/", ignored_normalized)
Expand All @@ -1455,7 +1470,7 @@ def test_add_multiple_directories(self) -> None:
# Should add all files from all directories
self.assertEqual(len(added), 6)
# Normalize paths to use forward slashes for comparison
added_normalized = [path.replace(os.sep, "/") for path in added]
added_normalized = self._normalize_paths(added)
for dirname in ["dir1", "dir2", "dir3"]:
for i in range(2):
self.assertIn(f"{dirname}/file{i}.txt", added_normalized)
Expand Down Expand Up @@ -1491,8 +1506,9 @@ def test_add_default_paths_includes_modified_files(self) -> None:
added_files, ignored_files = porcelain.add(repo=self.repo.path)

# Verify both files were added
self.assertIn("existing.txt", added_files)
self.assertIn("new.txt", added_files)
added_normalized = self._normalize_paths(added_files)
self.assertIn("existing.txt", added_normalized)
self.assertIn("new.txt", added_normalized)
self.assertEqual(len(ignored_files), 0)

# Verify both files are now staged
Expand Down Expand Up @@ -2677,11 +2693,15 @@ def test_checkout_to_branch_with_untracked_files(self) -> None:
f.write("new message\n")

status = list(porcelain.status(self.repo))
# Convert bytes to strings for comparison
status[2] = [f.decode() if isinstance(f, bytes) else f for f in status[2]]
self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["neu"]], status)

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

status = list(porcelain.status(self.repo))
# Convert bytes to strings for comparison
status[2] = [f.decode() if isinstance(f, bytes) else f for f in status[2]]
self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["neu"]], status)

def test_checkout_to_branch_with_new_files(self) -> None:
Expand Down Expand Up @@ -3706,6 +3726,7 @@ def test_status_all(self) -> None:
results.staged,
)
self.assertListEqual(results.unstaged, [b"blye"])
self.assertListEqual(results.untracked, [b"blyat"])
results_no_untracked = porcelain.status(self.repo.path, untracked_files="no")
self.assertListEqual(results_no_untracked.untracked, [])

Expand All @@ -3721,7 +3742,7 @@ def test_status_untracked_path(self) -> None:
fh.write("untracked")

_, _, untracked = porcelain.status(self.repo.path, untracked_files="all")
self.assertEqual(untracked, ["untracked_dir/untracked_file"])
self.assertEqual(untracked, [b"untracked_dir/untracked_file"])

def test_status_crlf_mismatch(self) -> None:
# First make a commit as if the file has been added on a Linux system
Expand Down Expand Up @@ -3896,19 +3917,19 @@ def test_get_untracked_paths(self) -> None:
os.path.join(self.repo.path, "link"),
)
self.assertEqual(
{"ignored", "notignored", ".gitignore", "link"},
{b"ignored", b"notignored", b".gitignore", b"link"},
set(
porcelain.get_untracked_paths(
self.repo.path, self.repo.path, self.repo.open_index()
)
),
)
self.assertEqual(
{".gitignore", "notignored", "link"},
{b".gitignore", b"notignored", b"link"},
set(porcelain.status(self.repo).untracked),
)
self.assertEqual(
{".gitignore", "notignored", "ignored", "link"},
{b".gitignore", b"notignored", b"ignored", b"link"},
set(porcelain.status(self.repo, ignored=True).untracked),
)

Expand All @@ -3927,15 +3948,15 @@ def test_get_untracked_paths_subrepo(self) -> None:
f.write("blop\n")

self.assertEqual(
{".gitignore", "notignored", os.path.join("nested", "")},
{b".gitignore", b"notignored", os.fsencode(os.path.join("nested", ""))},
set(
porcelain.get_untracked_paths(
self.repo.path, self.repo.path, self.repo.open_index()
)
),
)
self.assertEqual(
{".gitignore", "notignored"},
{b".gitignore", b"notignored"},
set(
porcelain.get_untracked_paths(
self.repo.path,
Expand All @@ -3946,7 +3967,7 @@ def test_get_untracked_paths_subrepo(self) -> None:
),
)
self.assertEqual(
{"ignored", "with", "manager"},
{b"ignored", b"with", b"manager"},
set(
porcelain.get_untracked_paths(
subrepo.path, subrepo.path, subrepo.open_index()
Expand All @@ -3965,9 +3986,9 @@ def test_get_untracked_paths_subrepo(self) -> None:
)
self.assertEqual(
{
os.path.join("nested", "ignored"),
os.path.join("nested", "with"),
os.path.join("nested", "manager"),
os.fsencode(os.path.join("nested", "ignored")),
os.fsencode(os.path.join("nested", "with")),
os.fsencode(os.path.join("nested", "manager")),
},
set(
porcelain.get_untracked_paths(
Expand All @@ -3991,10 +4012,10 @@ def test_get_untracked_paths_subdir(self) -> None:

self.assertEqual(
{
".gitignore",
"notignored",
"ignored",
os.path.join("subdir", ""),
b".gitignore",
b"notignored",
b"ignored",
os.fsencode(os.path.join("subdir", "")),
},
set(
porcelain.get_untracked_paths(
Expand All @@ -4005,7 +4026,7 @@ def test_get_untracked_paths_subdir(self) -> None:
),
)
self.assertEqual(
{".gitignore", "notignored"},
{b".gitignore", b"notignored"},
set(
porcelain.get_untracked_paths(
self.repo.path,
Expand Down Expand Up @@ -4044,15 +4065,15 @@ def test_get_untracked_paths_top_level_issue_1247(self) -> None:
)
)
self.assertIn(
"sample.txt",
b"sample.txt",
untracked,
"Top-level file 'sample.txt' should be in untracked list",
)

# Test via status
status = porcelain.status(self.repo)
self.assertIn(
"sample.txt",
b"sample.txt",
status.untracked,
"Top-level file 'sample.txt' should be in status.untracked",
)
Expand Down
6 changes: 6 additions & 0 deletions tests/test_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -1553,6 +1553,8 @@ def test_unstage_while_no_commit(self) -> None:
porcelain.add(self._repo, paths=[full_path])
self._repo.unstage([file])
status = list(porcelain.status(self._repo))
# Convert bytes to strings for comparison
status[2] = [f.decode() if isinstance(f, bytes) else f for f in status[2]]
self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["foo"]], status)

def test_unstage_add_file(self) -> None:
Expand All @@ -1569,6 +1571,8 @@ def test_unstage_add_file(self) -> None:
porcelain.add(self._repo, paths=[full_path])
self._repo.unstage([file])
status = list(porcelain.status(self._repo))
# Convert bytes to strings for comparison
status[2] = [f.decode() if isinstance(f, bytes) else f for f in status[2]]
self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["foo"]], status)

def test_unstage_modify_file(self) -> None:
Expand Down Expand Up @@ -1625,6 +1629,8 @@ def test_reset_index(self) -> None:
)
r.reset_index()
status = list(porcelain.status(self._repo))
# Convert bytes to strings for comparison
status[2] = [f.decode() if isinstance(f, bytes) else f for f in status[2]]
self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["b"]], status)

@skipIf(
Expand Down