@@ -33,25 +33,35 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
3333
3434
3535class Repository :
36- def __init__ (self , root_path : Path , rev : str | None = None ):
36+ def __init__ (self , root_path : Path , rev : str | None = None , subdir : Path | None = None ):
3737 self .root_path : Path = root_path
38+ if subdir is not None and subdir .is_absolute ():
39+ subdir = subdir .relative_to (root_path )
40+ self .subdir : Path | None = subdir
3841 self .rev : str = ""
3942 if rev is None :
4043 with self :
4144 if self .is_git :
4245 git_path : str = GIT_PATH # type: ignore
4346 self .rev = (
44- subprocess .check_output ([git_path , "rev-parse" , "HEAD" ], cwd = self .root_path ) # noqa: S603
47+ subprocess .check_output ([git_path , "rev-parse" , "HEAD" ], cwd = self .path ) # noqa: S603
4548 .strip ()
4649 .decode ("utf-8" )
4750 )
4851 else :
4952 self .rev = rev
5053
54+ @property
55+ @with_self
56+ def path (self ) -> Path :
57+ if self .subdir is None :
58+ return self .root_path
59+ return self .root_path / self .subdir
60+
5161 def __hash__ (self ) -> int :
5262 if self .rev :
5363 return hash (self .rev )
54- return hash (self .root_path )
64+ return hash (self .path )
5565
5666 def __eq__ (self , other : object ) -> bool :
5767 if not isinstance (other , Repository ):
@@ -60,7 +70,7 @@ def __eq__(self, other: object) -> bool:
6070 return self .rev == other .rev
6171 if self .rev or other .rev :
6272 return False
63- return self .root_path == other .root_path
73+ return self .path == other .path
6474
6575 def __enter__ (self ) -> Self :
6676 return self
@@ -87,7 +97,7 @@ def previous_version(self, path: Path) -> Optional["RepositoryCommit"]:
8797 )
8898 raise RepositoryError (msg )
8999 if path .is_absolute ():
90- path = path .relative_to (self .root_path )
100+ path = path .relative_to (self .path )
91101 prev_version = (
92102 subprocess .check_output ( # noqa: S603
93103 [
@@ -102,7 +112,7 @@ def previous_version(self, path: Path) -> Optional["RepositoryCommit"]:
102112 "--" ,
103113 str (path ),
104114 ],
105- cwd = self .root_path ,
115+ cwd = self .path ,
106116 )
107117 .decode ("utf-8" )
108118 .strip ()
@@ -121,34 +131,70 @@ def is_shallow_clone(self) -> bool:
121131 return (
122132 subprocess .check_output ( # noqa: S603
123133 [GIT_PATH , "rev-parse" , "--is-shallow-repository" ], # type: ignore
124- cwd = self .root_path ,
134+ cwd = self .path ,
125135 stderr = subprocess .DEVNULL ,
126136 ).strip ()
127137 != b"false"
128138 )
129139 except subprocess .CalledProcessError :
130140 return False
131141
142+ @property
143+ @with_self
144+ def git_root (self ) -> Path | None :
145+ if GIT_PATH is None :
146+ return None
147+ try :
148+ return Path (
149+ subprocess .check_output ( # noqa: S603
150+ [GIT_PATH , "-C" , str (self .path ), "rev-parse" , "--show-toplevel" ],
151+ stderr = subprocess .DEVNULL ,
152+ )
153+ .strip ()
154+ .decode ("utf-8" )
155+ )
156+ except subprocess .CalledProcessError :
157+ return None
158+
159+ @with_self
160+ def is_inside_git_work_tree (self ) -> bool :
161+ if GIT_PATH is None :
162+ return False
163+ try :
164+ return (
165+ subprocess .check_output ( # noqa: S603
166+ [GIT_PATH , "-C" , str (self .path ), "rev-parse" , "--is-inside-work-tree" ],
167+ stderr = subprocess .DEVNULL ,
168+ )
169+ .strip ()
170+ .lower ()
171+ == b"true"
172+ )
173+ except subprocess .CalledProcessError :
174+ return False
175+
132176 @property
133177 @with_self
134178 def is_git (self ) -> bool :
135- return self .root_path .is_dir () and (self .root_path / ".git" ).is_dir ()
179+ return self .root_path .is_dir () and (( self .root_path / ".git" ).is_dir () or self . is_inside_git_work_tree () )
136180
137181 @with_self
138182 def git_files (self ) -> Iterator ["File" ]:
139183 if GIT_PATH is None :
140184 msg = "`git` binary could not be found"
141185 raise RepositoryError (msg )
142- for line in subprocess .check_output ([GIT_PATH , "ls-files" ], cwd = self .root_path ).splitlines (): # noqa: S603
186+ for line in subprocess .check_output ([GIT_PATH , "ls-files" ], cwd = self .path ).splitlines (): # noqa: S603
143187 line = line .strip () # noqa: PLW2901
144188 if line :
145189 path = Path (line .decode ("utf-8" ))
190+ if self .subdir is not None :
191+ path = self .subdir / path
146192 yield File (path , self )
147193
148194 @with_self
149195 def files (self ) -> Iterator ["File" ]:
150196 if GIT_PATH is None or not self .is_git :
151- stack : list [File ] = [File (self .root_path , self )]
197+ stack : list [File ] = [File (self .path , self )]
152198 else :
153199 stack = list (reversed (list (self .git_files ())))
154200 history = set ()
@@ -166,33 +212,38 @@ def __iter__(self) -> Iterator["File"]:
166212 yield from self .files ()
167213
168214 def __repr__ (self ) -> str :
169- return f"{ self .__class__ .__name__ } ({ self .root_path !r} )"
215+ return f"{ self .__class__ .__name__ } ({ self .root_path !r} , rev= { self . rev !r } , subdir= { self . subdir !r } )"
170216
171217 def __str__ (self ) -> str :
172218 if self .rev :
173- return f"{ self .root_path !s} @{ self .rev } "
174- return f"{ self .root_path !s} "
219+ return f"{ self .path !s} @{ self .rev } "
220+ return f"{ self .path !s} "
175221
176222 @classmethod
177- def load (cls , repo_uri : str ) -> "Repository" :
223+ def load (cls , repo_uri : str , subdir : Path | str | None = None ) -> "Repository" :
178224 # first see if it is a local repo
179225 repo_uri_path = Path (repo_uri ).absolute ()
226+ if subdir is not None and not isinstance (subdir , Path ):
227+ subdir = Path (subdir )
180228 if repo_uri_path .exists () and repo_uri_path .is_dir ():
181- return Repository (repo_uri_path )
182- return RemoteGitRepository (repo_uri )
229+ return Repository (repo_uri_path , subdir = subdir )
230+ return RemoteGitRepository (repo_uri , subdir = subdir )
183231
184232
185233class _ClonedRepository (Repository ):
186- def __init__ (self , clone_uri : str , rev : str | None = None ):
234+ def __init__ (self , clone_uri : str , rev : str | None = None , subdir : Path | None = None ):
187235 self ._clone_uri : str = clone_uri
188236 self ._entries : int = 0
189237 self ._tempdir : TemporaryDirectory | None = None
238+ if subdir is not None and subdir .is_absolute ():
239+ msg = f"Invalid subdirectory { subdir !s} : the path must be relative, not absolute"
240+ raise ValueError (msg )
190241 if GIT_PATH is None :
191242 msg = (
192243 f"Error cloning { self ._clone_uri } : `git` binary could not be found;please make sure it is in your PATH"
193244 )
194245 raise RepositoryError (msg )
195- super ().__init__ (Path (), rev = rev )
246+ super ().__init__ (Path (), rev = rev , subdir = subdir )
196247
197248 def __enter__ (self ) -> Self :
198249 self ._entries += 1
@@ -329,12 +380,12 @@ def __str__(self) -> str:
329380
330381
331382class RemoteGitRepository (_ClonedRepository ):
332- def __init__ (self , url : str ):
383+ def __init__ (self , url : str , subdir : Path | None = None ):
333384 self .url : str = url
334- super ().__init__ (url , rev = "" )
385+ super ().__init__ (url , rev = "" , subdir = subdir )
335386
336387 def __repr__ (self ) -> str :
337- return f"{ self .__class__ .__name__ } ({ self .url !r} )"
388+ return f"{ self .__class__ .__name__ } ({ self .url !r} , subdir= { self . subdir !r } )"
338389
339390 @property
340391 def is_git (self ) -> bool :
0 commit comments