@@ -7,11 +7,14 @@ pub mod model;
77pub mod paths;
88pub mod report;
99
10+ use std:: collections:: HashSet ;
1011use std:: io:: Write ;
1112use std:: path:: Path ;
1213use std:: time:: { Duration , Instant } ;
1314
1415use crate :: error:: { CodeM8Error , Result } ;
16+ use crate :: model:: ProcessedFile ;
17+ use crate :: paths:: format_path;
1518
1619/// Runs the CLI workflow and writes the selected report to the provided writer.
1720///
@@ -31,23 +34,31 @@ where
3134 . map_err ( |error| CodeM8Error :: new ( format ! ( "could not write help output: {error}" ) ) ) ?,
3235 cli:: CliCommand :: ReportDuplicate ( config) => {
3336 let should_report_scanned_files = config. git_branch || config. files . is_some ( ) ;
37+ let git_branch_files = if config. git_branch {
38+ Some ( discovery:: changed_files_against_origin ( current_dir) ?)
39+ } else {
40+ None
41+ } ;
3442 let ( source_files, discovery_duration) = time_result ( config. verbose , || {
35- let git_branch_files = if config. git_branch {
36- Some ( discovery:: changed_files_against_origin ( current_dir) ?)
37- } else {
38- None
39- } ;
4043 discovery:: discover_source_files (
4144 current_dir,
4245 & config. file_extensions ,
43- git_branch_files. as_deref ( ) . or ( config. files . as_deref ( ) ) ,
46+ if config. git_branch {
47+ None
48+ } else {
49+ config. files . as_deref ( )
50+ } ,
4451 )
4552 } ) ?;
4653 let ( processed_files, file_processing_duration) =
4754 time_result ( config. verbose , || line:: process_source_files ( & source_files) ) ?;
55+ let duplicate_source_files = git_branch_files. as_deref ( ) . map_or_else (
56+ || processed_files. clone ( ) ,
57+ |git_branch_files| filtered_processed_files ( & processed_files, git_branch_files) ,
58+ ) ;
4859 let ( duplicate_blocks, duplicate_detection_duration) =
4960 time_value ( config. verbose , || {
50- report:: detect_duplicate_blocks ( & processed_files )
61+ report:: detect_duplicate_blocks ( & duplicate_source_files )
5162 } ) ;
5263 let report = report:: DuplicateReport {
5364 analyzed_files : source_files. len ( ) ,
@@ -99,10 +110,28 @@ fn time_value<T>(enabled: bool, operation: impl FnOnce() -> T) -> (T, Option<Dur
99110 ( value, started_at. map ( |instant| instant. elapsed ( ) ) )
100111}
101112
113+ fn filtered_processed_files (
114+ processed_files : & [ ProcessedFile ] ,
115+ selected_files : & [ std:: path:: PathBuf ] ,
116+ ) -> Vec < ProcessedFile > {
117+ let selected_files = selected_files
118+ . iter ( )
119+ . map ( |path| format_path ( path) )
120+ . collect :: < HashSet < _ > > ( ) ;
121+ processed_files
122+ . iter ( )
123+ . filter ( |processed_file| {
124+ selected_files. contains ( & format_path ( & processed_file. source . display_path ) )
125+ } )
126+ . cloned ( )
127+ . collect ( )
128+ }
129+
102130#[ cfg( test) ]
103131mod tests {
104132 use std:: fs;
105133 use std:: path:: { Path , PathBuf } ;
134+ use std:: process:: Command ;
106135 use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
107136
108137 use super :: * ;
@@ -132,24 +161,96 @@ mod tests {
132161 }
133162 fs:: write ( path, contents) . expect ( "write test file" ) ;
134163 }
164+ }
135165
136- fn path ( & self ) -> & Path {
166+ impl Drop for TempProject {
167+ fn drop ( & mut self ) {
168+ let _ = fs:: remove_dir_all ( & self . path ) ;
169+ }
170+ }
171+
172+ impl AsRef < Path > for TempProject {
173+ fn as_ref ( & self ) -> & Path {
137174 & self . path
138175 }
139176 }
140177
141- impl Drop for TempProject {
178+ struct TempGitRepo {
179+ path : PathBuf ,
180+ }
181+
182+ impl TempGitRepo {
183+ fn new ( name : & str ) -> Self {
184+ let id = TEMP_COUNTER . fetch_add ( 1 , Ordering :: Relaxed ) ;
185+ let path =
186+ std:: env:: temp_dir ( ) . join ( format ! ( "codem8-git-{name}-{}-{id}" , std:: process:: id( ) ) ) ;
187+ if path. exists ( ) {
188+ fs:: remove_dir_all ( & path) . expect ( "remove stale test directory" ) ;
189+ }
190+ fs:: create_dir_all ( & path) . expect ( "create test directory" ) ;
191+ Self { path }
192+ }
193+
194+ fn write ( & self , relative_path : & str , contents : & str ) {
195+ let path = self . path . join ( relative_path) ;
196+ if let Some ( parent) = path. parent ( ) {
197+ fs:: create_dir_all ( parent) . expect ( "create test parent directory" ) ;
198+ }
199+ fs:: write ( path, contents) . expect ( "write test file" ) ;
200+ }
201+
202+ fn git ( & self , args : & [ & str ] ) {
203+ let status = Command :: new ( "git" )
204+ . arg ( "-C" )
205+ . arg ( & self . path )
206+ . args ( args)
207+ . status ( )
208+ . expect ( "run git" ) ;
209+ assert ! ( status. success( ) , "git command failed: {args:?}" ) ;
210+ }
211+
212+ fn commit ( & self , message : & str ) {
213+ self . git ( & [ "add" , "." ] ) ;
214+ self . git ( & [
215+ "-c" ,
216+ "user.name=CodeM8 Test" ,
217+ "-c" ,
218+ "user.email=codem8@example.invalid" ,
219+ "commit" ,
220+ "-m" ,
221+ message,
222+ ] ) ;
223+ }
224+ }
225+
226+ impl Drop for TempGitRepo {
142227 fn drop ( & mut self ) {
143228 let _ = fs:: remove_dir_all ( & self . path ) ;
144229 }
145230 }
146231
147- fn run_in ( project : & TempProject , args : & [ & str ] ) -> std:: result:: Result < String , CodeM8Error > {
232+ impl AsRef < Path > for TempGitRepo {
233+ fn as_ref ( & self ) -> & Path {
234+ & self . path
235+ }
236+ }
237+
238+ fn run_in < P : AsRef < Path > > (
239+ project : P ,
240+ args : & [ & str ] ,
241+ ) -> std:: result:: Result < String , CodeM8Error > {
148242 let mut output = Vec :: new ( ) ;
149- run ( args. iter ( ) . copied ( ) , project. path ( ) , & mut output) ?;
243+ run ( args. iter ( ) . copied ( ) , project. as_ref ( ) , & mut output) ?;
150244 Ok ( String :: from_utf8 ( output) . expect ( "report is UTF-8" ) )
151245 }
152246
247+ fn git_is_available ( ) -> bool {
248+ Command :: new ( "git" )
249+ . arg ( "--version" )
250+ . status ( )
251+ . is_ok_and ( |status| status. success ( ) )
252+ }
253+
153254 #[ test]
154255 fn duplicate_report_snapshot_is_stable ( ) {
155256 let project = TempProject :: new ( "snapshot" ) ;
@@ -258,6 +359,25 @@ mod tests {
258359 assert ! ( js_output. contains( "Duplicate blocks found: 1" ) ) ;
259360 }
260361
362+ #[ test]
363+ fn git_branch_mode_limits_duplicate_search_to_changed_files ( ) {
364+ if !git_is_available ( ) {
365+ return ;
366+ }
367+ let project = TempGitRepo :: new ( "git-branch-scope" ) ;
368+ project. git ( & [ "init" ] ) ;
369+ project. write ( "src/a.ts" , "const original = 1;\n " ) ;
370+ project. write ( "src/b.ts" , "const shared = 1;\n " ) ;
371+ project. commit ( "initial" ) ;
372+ project. git ( & [ "update-ref" , "refs/remotes/origin/main" , "HEAD" ] ) ;
373+ project. git ( & [ "branch" , "-M" , "feature" ] ) ;
374+ project. write ( "src/a.ts" , "const shared = 1;\n " ) ;
375+ let output =
376+ run_in ( & project, & [ "--report-duplicate" , "-git-branch" ] ) . expect ( "report succeeds" ) ;
377+ assert ! ( output. contains( "Number of files scanned: 2" ) ) ;
378+ assert ! ( output. contains( "Duplicate blocks found: 0" ) ) ;
379+ }
380+
261381 #[ test]
262382 fn invalid_explicit_file_returns_a_clear_error ( ) {
263383 let project = TempProject :: new ( "invalid-file" ) ;
0 commit comments