Skip to content

Commit 7650b14

Browse files
authored
Two fixes for add handling (#1578)
* Fix ``porcelain.add()`` when adding repository root path or directories. Previously, adding the repository path itself would incorrectly stage ``b'./'`` instead of the actual untracked files, leading to repository corruption. Fixed #1178 * Improve ``porcelain.add()`` documentation to correctly describe default behavior. Fixes #895
2 parents 6b3e97e + 3528f83 commit 7650b14

File tree

3 files changed

+251
-17
lines changed

3 files changed

+251
-17
lines changed

NEWS

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66
Now only resolves the parent directory for symlinks, matching Git's behavior.
77
(Jelmer Vernooij, #789)
88

9+
* Fix ``porcelain.add()`` when adding repository root path or directories.
10+
Previously, adding the repository path itself would incorrectly stage
11+
``b'./'`` instead of the actual untracked files, leading to
12+
repository corruption. (Jelmer Vernooij, #1178)
13+
14+
* Improve ``porcelain.add()`` documentation to correctly describe default
15+
behavior. (Jelmer Vernooij, #895)
16+
917
* Fix gitignore pattern matching for directory negation patterns. Patterns like
1018
``!data/*/`` now correctly unignore direct subdirectories while still ignoring
1119
files in the parent directory, matching Git's behavior. The ``is_ignored()`` method

dulwich/porcelain.py

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -587,12 +587,17 @@ def add(repo=".", paths=None):
587587
588588
Args:
589589
repo: Repository for the files
590-
paths: Paths to add. No value passed stages all modified files.
590+
paths: Paths to add. If None, stages all untracked files from the
591+
current working directory (mimicking 'git add .' behavior).
591592
Returns: Tuple with set of added files and ignored files
592593
593594
If the repository contains ignored directories, the returned set will
594595
contain the path to an ignored directory (with trailing slash). Individual
595596
files within ignored directories will not be returned.
597+
598+
Note: When paths=None, this function respects the current working directory,
599+
so if called from a subdirectory, it will only add files from that
600+
subdirectory and below, matching Git's behavior.
596601
"""
597602
ignored = set()
598603
with open_repo_closing(repo) as r:
@@ -620,25 +625,50 @@ def add(repo=".", paths=None):
620625
# Make relative paths relative to the repo directory
621626
path = repo_path / path
622627

623-
try:
624-
# Don't resolve symlinks completely - only resolve the parent directory
625-
# to avoid issues when symlinks point outside the repository
626-
if path.is_symlink():
627-
# For symlinks, resolve only the parent directory
628-
parent_resolved = path.parent.resolve()
629-
resolved_path = parent_resolved / path.name
630-
else:
631-
# For regular files/dirs, resolve normally
632-
resolved_path = path.resolve()
628+
# Don't resolve symlinks completely - only resolve the parent directory
629+
# to avoid issues when symlinks point outside the repository
630+
if path.is_symlink():
631+
# For symlinks, resolve only the parent directory
632+
parent_resolved = path.parent.resolve()
633+
resolved_path = parent_resolved / path.name
634+
else:
635+
# For regular files/dirs, resolve normally
636+
resolved_path = path.resolve()
633637

634-
relpath = str(resolved_path.relative_to(repo_path))
638+
try:
639+
relpath = str(resolved_path.relative_to(repo_path)).replace(os.sep, "/")
635640
except ValueError:
636641
# Path is not within the repository
637642
raise ValueError(f"Path {p} is not within repository {repo_path}")
638643

644+
# Handle directories by scanning their contents
645+
if resolved_path.is_dir():
646+
# Check if the directory itself is ignored
647+
dir_relpath = posixpath.join(relpath, "") if relpath != "." else ""
648+
if dir_relpath and ignore_manager.is_ignored(dir_relpath):
649+
ignored.add(dir_relpath)
650+
continue
651+
652+
# When adding a directory, add all untracked files within it
653+
current_untracked = list(
654+
get_untracked_paths(
655+
str(resolved_path),
656+
str(repo_path),
657+
r.open_index(),
658+
)
659+
)
660+
for untracked_path in current_untracked:
661+
# If we're scanning a subdirectory, adjust the path
662+
if relpath != ".":
663+
untracked_path = posixpath.join(relpath, untracked_path)
664+
665+
if not ignore_manager.is_ignored(untracked_path):
666+
relpaths.append(untracked_path)
667+
else:
668+
ignored.add(untracked_path)
669+
continue
670+
639671
# FIXME: Support patterns
640-
if path.is_dir():
641-
relpath = os.path.join(relpath, "")
642672
if ignore_manager.is_ignored(relpath):
643673
ignored.add(relpath)
644674
continue

tests/test_porcelain.py

Lines changed: 199 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -920,9 +920,12 @@ def test_add_default_paths(self) -> None:
920920
try:
921921
os.chdir(self.repo.path)
922922
self.assertEqual({"foo", "blah", "adir", ".git"}, set(os.listdir(".")))
923+
added, ignored = porcelain.add(self.repo.path)
924+
# Normalize paths to use forward slashes for comparison
925+
added_normalized = [path.replace(os.sep, "/") for path in added]
923926
self.assertEqual(
924-
(["foo", os.path.join("adir", "afile")], set()),
925-
porcelain.add(self.repo.path),
927+
(added_normalized, ignored),
928+
(["foo", "adir/afile"], set()),
926929
)
927930
finally:
928931
os.chdir(cwd)
@@ -981,7 +984,7 @@ def test_add_ignored(self) -> None:
981984
)
982985
self.assertIn(b"bar", self.repo.open_index())
983986
self.assertEqual({"bar"}, set(added))
984-
self.assertEqual({"foo", os.path.join("subdir", "")}, ignored)
987+
self.assertEqual({"foo", "subdir/"}, ignored)
985988

986989
def test_add_file_absolute_path(self) -> None:
987990
# Absolute paths are (not yet) supported
@@ -1044,6 +1047,199 @@ def test_add_symlink_outside_repo(self) -> None:
10441047
index = self.repo.open_index()
10451048
self.assertIn(b"symlink_to_nowhere", index)
10461049

1050+
def test_add_repo_path(self) -> None:
1051+
"""Test adding the repository path itself should add all untracked files."""
1052+
# Create some untracked files
1053+
with open(os.path.join(self.repo.path, "file1.txt"), "w") as f:
1054+
f.write("content1")
1055+
with open(os.path.join(self.repo.path, "file2.txt"), "w") as f:
1056+
f.write("content2")
1057+
1058+
# Add the repository path itself
1059+
added, ignored = porcelain.add(self.repo.path, paths=[self.repo.path])
1060+
1061+
# Should add all untracked files, not stage './'
1062+
self.assertIn("file1.txt", added)
1063+
self.assertIn("file2.txt", added)
1064+
self.assertNotIn("./", added)
1065+
1066+
# Verify files are actually staged
1067+
index = self.repo.open_index()
1068+
self.assertIn(b"file1.txt", index)
1069+
self.assertIn(b"file2.txt", index)
1070+
1071+
def test_add_directory_contents(self) -> None:
1072+
"""Test adding a directory adds all files within it."""
1073+
# Create a subdirectory with multiple files
1074+
subdir = os.path.join(self.repo.path, "subdir")
1075+
os.mkdir(subdir)
1076+
with open(os.path.join(subdir, "file1.txt"), "w") as f:
1077+
f.write("content1")
1078+
with open(os.path.join(subdir, "file2.txt"), "w") as f:
1079+
f.write("content2")
1080+
with open(os.path.join(subdir, "file3.txt"), "w") as f:
1081+
f.write("content3")
1082+
1083+
# Add the directory
1084+
added, ignored = porcelain.add(self.repo.path, paths=["subdir"])
1085+
1086+
# Should add all files in the directory
1087+
self.assertEqual(len(added), 3)
1088+
# Normalize paths to use forward slashes for comparison
1089+
added_normalized = [path.replace(os.sep, "/") for path in added]
1090+
self.assertIn("subdir/file1.txt", added_normalized)
1091+
self.assertIn("subdir/file2.txt", added_normalized)
1092+
self.assertIn("subdir/file3.txt", added_normalized)
1093+
1094+
# Verify files are actually staged
1095+
index = self.repo.open_index()
1096+
self.assertIn(b"subdir/file1.txt", index)
1097+
self.assertIn(b"subdir/file2.txt", index)
1098+
self.assertIn(b"subdir/file3.txt", index)
1099+
1100+
def test_add_nested_directories(self) -> None:
1101+
"""Test adding a directory with nested subdirectories."""
1102+
# Create nested directory structure
1103+
dir1 = os.path.join(self.repo.path, "dir1")
1104+
dir2 = os.path.join(dir1, "dir2")
1105+
dir3 = os.path.join(dir2, "dir3")
1106+
os.makedirs(dir3)
1107+
1108+
# Add files at each level
1109+
with open(os.path.join(dir1, "file1.txt"), "w") as f:
1110+
f.write("level1")
1111+
with open(os.path.join(dir2, "file2.txt"), "w") as f:
1112+
f.write("level2")
1113+
with open(os.path.join(dir3, "file3.txt"), "w") as f:
1114+
f.write("level3")
1115+
1116+
# Add the top-level directory
1117+
added, ignored = porcelain.add(self.repo.path, paths=["dir1"])
1118+
1119+
# Should add all files recursively
1120+
self.assertEqual(len(added), 3)
1121+
# Normalize paths to use forward slashes for comparison
1122+
added_normalized = [path.replace(os.sep, "/") for path in added]
1123+
self.assertIn("dir1/file1.txt", added_normalized)
1124+
self.assertIn("dir1/dir2/file2.txt", added_normalized)
1125+
self.assertIn("dir1/dir2/dir3/file3.txt", added_normalized)
1126+
1127+
# Verify files are actually staged
1128+
index = self.repo.open_index()
1129+
self.assertIn(b"dir1/file1.txt", index)
1130+
self.assertIn(b"dir1/dir2/file2.txt", index)
1131+
self.assertIn(b"dir1/dir2/dir3/file3.txt", index)
1132+
1133+
def test_add_directory_with_tracked_files(self) -> None:
1134+
"""Test adding a directory with some files already tracked."""
1135+
# Create a subdirectory with files
1136+
subdir = os.path.join(self.repo.path, "mixed")
1137+
os.mkdir(subdir)
1138+
1139+
# Create and commit one file
1140+
tracked_file = os.path.join(subdir, "tracked.txt")
1141+
with open(tracked_file, "w") as f:
1142+
f.write("already tracked")
1143+
porcelain.add(self.repo.path, paths=[tracked_file])
1144+
porcelain.commit(
1145+
repo=self.repo.path,
1146+
message=b"Add tracked file",
1147+
author=b"test <email>",
1148+
committer=b"test <email>",
1149+
)
1150+
1151+
# Add more untracked files
1152+
with open(os.path.join(subdir, "untracked1.txt"), "w") as f:
1153+
f.write("new file 1")
1154+
with open(os.path.join(subdir, "untracked2.txt"), "w") as f:
1155+
f.write("new file 2")
1156+
1157+
# Add the directory
1158+
added, ignored = porcelain.add(self.repo.path, paths=["mixed"])
1159+
1160+
# Should only add the untracked files
1161+
self.assertEqual(len(added), 2)
1162+
# Normalize paths to use forward slashes for comparison
1163+
added_normalized = [path.replace(os.sep, "/") for path in added]
1164+
self.assertIn("mixed/untracked1.txt", added_normalized)
1165+
self.assertIn("mixed/untracked2.txt", added_normalized)
1166+
self.assertNotIn("mixed/tracked.txt", added)
1167+
1168+
# Verify the index contains all files
1169+
index = self.repo.open_index()
1170+
self.assertIn(b"mixed/tracked.txt", index)
1171+
self.assertIn(b"mixed/untracked1.txt", index)
1172+
self.assertIn(b"mixed/untracked2.txt", index)
1173+
1174+
def test_add_directory_with_gitignore(self) -> None:
1175+
"""Test adding a directory respects .gitignore patterns."""
1176+
# Create .gitignore
1177+
with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
1178+
f.write("*.log\n*.tmp\nbuild/\n")
1179+
1180+
# Create directory with mixed files
1181+
testdir = os.path.join(self.repo.path, "testdir")
1182+
os.mkdir(testdir)
1183+
1184+
# Create various files
1185+
with open(os.path.join(testdir, "important.txt"), "w") as f:
1186+
f.write("keep this")
1187+
with open(os.path.join(testdir, "debug.log"), "w") as f:
1188+
f.write("ignore this")
1189+
with open(os.path.join(testdir, "temp.tmp"), "w") as f:
1190+
f.write("ignore this too")
1191+
with open(os.path.join(testdir, "readme.md"), "w") as f:
1192+
f.write("keep this too")
1193+
1194+
# Create a build directory that should be ignored
1195+
builddir = os.path.join(testdir, "build")
1196+
os.mkdir(builddir)
1197+
with open(os.path.join(builddir, "output.txt"), "w") as f:
1198+
f.write("ignore entire directory")
1199+
1200+
# Add the directory
1201+
added, ignored = porcelain.add(self.repo.path, paths=["testdir"])
1202+
1203+
# Should only add non-ignored files
1204+
# Normalize paths to use forward slashes for comparison
1205+
added_normalized = {path.replace(os.sep, "/") for path in added}
1206+
self.assertEqual(
1207+
added_normalized, {"testdir/important.txt", "testdir/readme.md"}
1208+
)
1209+
1210+
# Check ignored files
1211+
# Normalize paths to use forward slashes for comparison
1212+
ignored_normalized = {path.replace(os.sep, "/") for path in ignored}
1213+
self.assertIn("testdir/debug.log", ignored_normalized)
1214+
self.assertIn("testdir/temp.tmp", ignored_normalized)
1215+
self.assertIn("testdir/build/", ignored_normalized)
1216+
1217+
def test_add_multiple_directories(self) -> None:
1218+
"""Test adding multiple directories in one call."""
1219+
# Create multiple directories
1220+
for dirname in ["dir1", "dir2", "dir3"]:
1221+
dirpath = os.path.join(self.repo.path, dirname)
1222+
os.mkdir(dirpath)
1223+
# Add files to each directory
1224+
for i in range(2):
1225+
with open(os.path.join(dirpath, f"file{i}.txt"), "w") as f:
1226+
f.write(f"content {dirname} {i}")
1227+
1228+
# Add all directories at once
1229+
added, ignored = porcelain.add(self.repo.path, paths=["dir1", "dir2", "dir3"])
1230+
1231+
# Should add all files from all directories
1232+
self.assertEqual(len(added), 6)
1233+
# Normalize paths to use forward slashes for comparison
1234+
added_normalized = [path.replace(os.sep, "/") for path in added]
1235+
for dirname in ["dir1", "dir2", "dir3"]:
1236+
for i in range(2):
1237+
self.assertIn(f"{dirname}/file{i}.txt", added_normalized)
1238+
1239+
# Verify all files are staged
1240+
index = self.repo.open_index()
1241+
self.assertEqual(len(index), 6)
1242+
10471243

10481244
class RemoveTests(PorcelainTestCase):
10491245
def test_remove_file(self) -> None:

0 commit comments

Comments
 (0)