@@ -2,8 +2,12 @@ use std::fs;
22
33use anyhow:: { bail, Context as _, Result } ;
44use clap:: { Arg , ArgMatches , Command } ;
5+ use log:: debug;
56use serde:: Deserialize ;
67
8+ use crate :: config:: Config ;
9+ use crate :: utils:: vcs;
10+
711#[ derive( Debug , Deserialize ) ]
812#[ serde( rename_all = "camelCase" ) ]
913struct CodeMapping {
@@ -30,8 +34,7 @@ pub fn make_command(command: Command) -> Command {
3034 Arg :: new ( "default_branch" )
3135 . long ( "default-branch" )
3236 . value_name ( "BRANCH" )
33- . default_value ( "main" )
34- . help ( "The default branch name." ) ,
37+ . help ( "The default branch name. Defaults to the git remote HEAD or 'main'." ) ,
3538 )
3639}
3740
@@ -57,7 +60,295 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
5760 }
5861 }
5962
63+ let explicit_repo = matches. get_one :: < String > ( "repo" ) ;
64+ let explicit_branch = matches. get_one :: < String > ( "default_branch" ) ;
65+
66+ let git_repo = ( explicit_repo. is_none ( ) || explicit_branch. is_none ( ) )
67+ . then ( || git2:: Repository :: open_from_env ( ) . ok ( ) )
68+ . flatten ( ) ;
69+
70+ let ( repo_name, default_branch) = resolve_repo_and_branch (
71+ explicit_repo. map ( |s| s. as_str ( ) ) ,
72+ explicit_branch. map ( |s| s. as_str ( ) ) ,
73+ git_repo. as_ref ( ) ,
74+ ) ?;
75+
6076 println ! ( "Found {} code mapping(s) in {path}" , mappings. len( ) ) ;
77+ println ! ( "Repository: {repo_name}" ) ;
78+ println ! ( "Default branch: {default_branch}" ) ;
6179
6280 Ok ( ( ) )
6381}
82+
83+ /// Resolves the repository name and default branch from explicit args and git inference.
84+ fn resolve_repo_and_branch (
85+ explicit_repo : Option < & str > ,
86+ explicit_branch : Option < & str > ,
87+ git_repo : Option < & git2:: Repository > ,
88+ ) -> Result < ( String , String ) > {
89+ let ( repo_name, remote_name) = if let Some ( r) = explicit_repo {
90+ // Try to find a local remote whose URL matches the explicit repo name,
91+ // so we can infer the default branch from it. Falls back to None (-> "main").
92+ let remote = git_repo. and_then ( |repo| find_remote_for_repo ( repo, r) ) ;
93+ ( r. to_owned ( ) , remote)
94+ } else {
95+ let remote = git_repo. and_then ( resolve_git_remote) ;
96+ let name = infer_repo_name ( git_repo, remote. as_deref ( ) ) ?;
97+ ( name, remote)
98+ } ;
99+
100+ let default_branch = if let Some ( b) = explicit_branch {
101+ b. to_owned ( )
102+ } else {
103+ infer_default_branch ( git_repo, remote_name. as_deref ( ) )
104+ } ;
105+
106+ Ok ( ( repo_name, default_branch) )
107+ }
108+
109+ /// Finds the best git remote name. Prefers the configured VCS remote
110+ /// (SENTRY_VCS_REMOTE / ini), then falls back to upstream > origin > first.
111+ fn resolve_git_remote ( repo : & git2:: Repository ) -> Option < String > {
112+ let config = Config :: current ( ) ;
113+ let configured_remote = config. get_cached_vcs_remote ( ) ;
114+ if vcs:: git_repo_remote_url ( repo, & configured_remote) . is_ok ( ) {
115+ debug ! ( "Using configured VCS remote: {configured_remote}" ) ;
116+ return Some ( configured_remote) ;
117+ }
118+ match vcs:: find_best_remote ( repo) {
119+ Ok ( Some ( best) ) => {
120+ debug ! ( "Configured remote '{configured_remote}' not found, using: {best}" ) ;
121+ Some ( best)
122+ }
123+ _ => None ,
124+ }
125+ }
126+
127+ /// Finds the remote whose URL matches the given repository name (e.g. "owner/repo").
128+ fn find_remote_for_repo ( repo : & git2:: Repository , repo_name : & str ) -> Option < String > {
129+ let remotes = repo. remotes ( ) . ok ( ) ?;
130+ let found = remotes. iter ( ) . flatten ( ) . find ( |name| {
131+ vcs:: git_repo_remote_url ( repo, name)
132+ . map ( |url| vcs:: get_repo_from_remote_preserve_case ( & url) == repo_name)
133+ . unwrap_or ( false )
134+ } ) ?;
135+ debug ! ( "Found remote '{found}' matching repo '{repo_name}'" ) ;
136+ Some ( found. to_owned ( ) )
137+ }
138+
139+ /// Infers the repository name (e.g. "owner/repo") from the git remote URL.
140+ fn infer_repo_name (
141+ git_repo : Option < & git2:: Repository > ,
142+ remote_name : Option < & str > ,
143+ ) -> Result < String > {
144+ let git_repo = git_repo. ok_or_else ( || {
145+ anyhow:: anyhow!( "Could not open git repository. Use --repo to specify manually." )
146+ } ) ?;
147+ let remote_name = remote_name. ok_or_else ( || {
148+ anyhow:: anyhow!( "No remotes found in the git repository. Use --repo to specify manually." )
149+ } ) ?;
150+ let remote_url = vcs:: git_repo_remote_url ( git_repo, remote_name) ?;
151+ debug ! ( "Found remote '{remote_name}': {remote_url}" ) ;
152+ let inferred = vcs:: get_repo_from_remote_preserve_case ( & remote_url) ;
153+ if inferred. is_empty ( ) {
154+ bail ! ( "Could not parse repository name from remote URL: {remote_url}" ) ;
155+ }
156+ Ok ( inferred)
157+ }
158+
159+ /// Infers the default branch from the git remote HEAD, falling back to "main".
160+ fn infer_default_branch ( git_repo : Option < & git2:: Repository > , remote_name : Option < & str > ) -> String {
161+ git_repo
162+ . zip ( remote_name)
163+ . and_then ( |( repo, name) | {
164+ vcs:: git_repo_base_ref ( repo, name)
165+ . map_err ( |e| {
166+ debug ! ( "Could not infer default branch from remote: {e}" ) ;
167+ e
168+ } )
169+ . ok ( )
170+ } )
171+ . unwrap_or_else ( || {
172+ debug ! ( "No git repo or remote available, falling back to 'main'" ) ;
173+ "main" . to_owned ( )
174+ } )
175+ }
176+
177+ #[ cfg( test) ]
178+ mod tests {
179+ use super :: * ;
180+ use std:: path:: PathBuf ;
181+
182+ use ini:: Ini ;
183+ use tempfile:: tempdir;
184+
185+ use crate :: config:: Config ;
186+
187+ fn init_git_repo_with_remotes ( remotes : & [ ( & str , & str ) ] ) -> tempfile:: TempDir {
188+ let dir = tempdir ( ) . expect ( "temp dir" ) ;
189+ std:: process:: Command :: new ( "git" )
190+ . args ( [ "init" , "--quiet" ] )
191+ . current_dir ( & dir)
192+ . env_remove ( "GIT_DIR" )
193+ . output ( )
194+ . expect ( "git init" ) ;
195+ for ( name, url) in remotes {
196+ std:: process:: Command :: new ( "git" )
197+ . args ( [ "remote" , "add" , name, url] )
198+ . current_dir ( & dir)
199+ . output ( )
200+ . expect ( "git remote add" ) ;
201+ }
202+ dir
203+ }
204+
205+ /// Creates a commit and sets up remote HEAD refs so branch inference works.
206+ fn setup_remote_head_refs (
207+ repo : & git2:: Repository ,
208+ dir : & std:: path:: Path ,
209+ branches : & [ ( & str , & str ) ] ,
210+ ) {
211+ for ( args, msg) in [
212+ ( vec ! [ "config" , "--local" , "user.name" , "test" ] , "git config" ) ,
213+ (
214+ vec ! [ "config" , "--local" , "user.email" , "test@test.com" ] ,
215+ "git config" ,
216+ ) ,
217+ ( vec ! [ "commit" , "--allow-empty" , "-m" , "init" ] , "git commit" ) ,
218+ ] {
219+ std:: process:: Command :: new ( "git" )
220+ . args ( & args)
221+ . current_dir ( dir)
222+ . output ( )
223+ . expect ( msg) ;
224+ }
225+
226+ let head_commit = repo. head ( ) . unwrap ( ) . peel_to_commit ( ) . unwrap ( ) . id ( ) ;
227+ for ( remote, branch) in branches {
228+ repo. reference (
229+ & format ! ( "refs/remotes/{remote}/{branch}" ) ,
230+ head_commit,
231+ false ,
232+ "test" ,
233+ )
234+ . unwrap ( ) ;
235+ repo. reference_symbolic (
236+ & format ! ( "refs/remotes/{remote}/HEAD" ) ,
237+ & format ! ( "refs/remotes/{remote}/{branch}" ) ,
238+ false ,
239+ "test" ,
240+ )
241+ . unwrap ( ) ;
242+ }
243+ }
244+
245+ /// Calls `resolve_repo_and_branch` with explicit args and a pre-opened git repo.
246+ fn run_resolve (
247+ git_repo : Option < & git2:: Repository > ,
248+ explicit_repo : Option < & str > ,
249+ explicit_branch : Option < & str > ,
250+ ) -> Result < ( String , String ) > {
251+ // Bind a default Config so resolve_git_remote can call Config::current().
252+ Config :: from_file ( PathBuf :: from ( "/dev/null" ) , Ini :: new ( ) ) . bind_to_process ( ) ;
253+
254+ resolve_repo_and_branch ( explicit_repo, explicit_branch, git_repo)
255+ }
256+
257+ #[ test]
258+ fn find_remote_for_repo_matches_upstream ( ) {
259+ let dir = init_git_repo_with_remotes ( & [
260+ ( "origin" , "https://github.com/my-fork/MyRepo" ) ,
261+ ( "upstream" , "https://github.com/MyOrg/MyRepo" ) ,
262+ ] ) ;
263+ let repo = git2:: Repository :: open ( dir. path ( ) ) . unwrap ( ) ;
264+ assert_eq ! (
265+ find_remote_for_repo( & repo, "MyOrg/MyRepo" ) ,
266+ Some ( "upstream" . to_owned( ) )
267+ ) ;
268+ }
269+
270+ #[ test]
271+ fn find_remote_for_repo_matches_origin ( ) {
272+ let dir = init_git_repo_with_remotes ( & [ ( "origin" , "https://github.com/MyOrg/MyRepo" ) ] ) ;
273+ let repo = git2:: Repository :: open ( dir. path ( ) ) . unwrap ( ) ;
274+ assert_eq ! (
275+ find_remote_for_repo( & repo, "MyOrg/MyRepo" ) ,
276+ Some ( "origin" . to_owned( ) )
277+ ) ;
278+ }
279+
280+ #[ test]
281+ fn find_remote_for_repo_no_match ( ) {
282+ let dir =
283+ init_git_repo_with_remotes ( & [ ( "origin" , "https://github.com/other-org/other-repo" ) ] ) ;
284+ let repo = git2:: Repository :: open ( dir. path ( ) ) . unwrap ( ) ;
285+ assert_eq ! ( find_remote_for_repo( & repo, "MyOrg/MyRepo" ) , None ) ;
286+ }
287+
288+ #[ test]
289+ fn find_remote_for_repo_preserves_case ( ) {
290+ let dir = init_git_repo_with_remotes ( & [ ( "origin" , "https://github.com/MyOrg/MyRepo" ) ] ) ;
291+ let repo = git2:: Repository :: open ( dir. path ( ) ) . unwrap ( ) ;
292+ assert_eq ! ( find_remote_for_repo( & repo, "myorg/myrepo" ) , None ) ;
293+ }
294+
295+ #[ test]
296+ fn resolve_no_repo_no_branch_infers_both ( ) {
297+ let dir = init_git_repo_with_remotes ( & [ ( "origin" , "https://github.com/MyOrg/MyRepo" ) ] ) ;
298+ let repo = git2:: Repository :: open ( dir. path ( ) ) . unwrap ( ) ;
299+ setup_remote_head_refs ( & repo, dir. path ( ) , & [ ( "origin" , "develop" ) ] ) ;
300+
301+ let ( repo_name, branch) = run_resolve ( Some ( & repo) , None , None ) . unwrap ( ) ;
302+ assert_eq ! ( repo_name, "MyOrg/MyRepo" ) ;
303+ assert_eq ! ( branch, "develop" ) ;
304+ }
305+
306+ #[ test]
307+ fn resolve_explicit_branch_no_repo_infers_repo ( ) {
308+ let dir = init_git_repo_with_remotes ( & [ ( "origin" , "https://github.com/MyOrg/MyRepo" ) ] ) ;
309+ let repo = git2:: Repository :: open ( dir. path ( ) ) . unwrap ( ) ;
310+
311+ let ( repo_name, branch) = run_resolve ( Some ( & repo) , None , Some ( "release" ) ) . unwrap ( ) ;
312+ assert_eq ! ( repo_name, "MyOrg/MyRepo" ) ;
313+ assert_eq ! ( branch, "release" ) ;
314+ }
315+
316+ #[ test]
317+ fn resolve_both_explicit_skips_git ( ) {
318+ let ( repo_name, branch) = run_resolve ( None , Some ( "MyOrg/MyRepo" ) , Some ( "release" ) ) . unwrap ( ) ;
319+ assert_eq ! ( repo_name, "MyOrg/MyRepo" ) ;
320+ assert_eq ! ( branch, "release" ) ;
321+ }
322+
323+ #[ test]
324+ fn resolve_explicit_repo_no_match_falls_back_to_main ( ) {
325+ let dir =
326+ init_git_repo_with_remotes ( & [ ( "origin" , "https://github.com/other-org/other-repo" ) ] ) ;
327+ let repo = git2:: Repository :: open ( dir. path ( ) ) . unwrap ( ) ;
328+
329+ let ( repo_name, branch) = run_resolve ( Some ( & repo) , Some ( "MyOrg/MyRepo" ) , None ) . unwrap ( ) ;
330+ assert_eq ! ( repo_name, "MyOrg/MyRepo" ) ;
331+ assert_eq ! ( branch, "main" ) ;
332+ }
333+
334+ #[ test]
335+ fn resolve_explicit_repo_infers_branch_from_matching_remote ( ) {
336+ // --repo matches "upstream", --default-branch omitted:
337+ // branch should be inferred from upstream's HEAD ("develop"),
338+ // not origin's ("master").
339+ let dir = init_git_repo_with_remotes ( & [
340+ ( "origin" , "https://github.com/my-fork/MyRepo" ) ,
341+ ( "upstream" , "https://github.com/MyOrg/MyRepo" ) ,
342+ ] ) ;
343+ let repo = git2:: Repository :: open ( dir. path ( ) ) . unwrap ( ) ;
344+ setup_remote_head_refs (
345+ & repo,
346+ dir. path ( ) ,
347+ & [ ( "origin" , "master" ) , ( "upstream" , "develop" ) ] ,
348+ ) ;
349+
350+ let ( repo_name, branch) = run_resolve ( Some ( & repo) , Some ( "MyOrg/MyRepo" ) , None ) . unwrap ( ) ;
351+ assert_eq ! ( repo_name, "MyOrg/MyRepo" ) ;
352+ assert_eq ! ( branch, "develop" ) ;
353+ }
354+ }
0 commit comments