@@ -32,16 +32,24 @@ type GitTokenStore struct {
3232 repoDir string
3333 configDir string
3434 remote string
35+ branch string
3536 username string
3637 password string
3738 lastGC time.Time
3839}
3940
41+ type resolvedRemoteBranch struct {
42+ name plumbing.ReferenceName
43+ hash plumbing.Hash
44+ }
45+
4046// NewGitTokenStore creates a token store that saves credentials to disk through the
4147// TokenStorage implementation embedded in the token record.
42- func NewGitTokenStore (remote , username , password string ) * GitTokenStore {
48+ // When branch is non-empty, clone/pull/push operations target that branch instead of the remote default.
49+ func NewGitTokenStore (remote , username , password , branch string ) * GitTokenStore {
4350 return & GitTokenStore {
4451 remote : remote ,
52+ branch : strings .TrimSpace (branch ),
4553 username : username ,
4654 password : password ,
4755 }
@@ -120,14 +128,25 @@ func (s *GitTokenStore) EnsureRepository() error {
120128 s .dirLock .Unlock ()
121129 return fmt .Errorf ("git token store: create repo dir: %w" , errMk )
122130 }
123- if _ , errClone := git .PlainClone (repoDir , & git.CloneOptions {Auth : authMethod , URL : s .remote }); errClone != nil {
131+ cloneOpts := & git.CloneOptions {Auth : authMethod , URL : s .remote }
132+ if s .branch != "" {
133+ cloneOpts .ReferenceName = plumbing .NewBranchReferenceName (s .branch )
134+ }
135+ if _ , errClone := git .PlainClone (repoDir , cloneOpts ); errClone != nil {
124136 if errors .Is (errClone , transport .ErrEmptyRemoteRepository ) {
125137 _ = os .RemoveAll (gitDir )
126138 repo , errInit := git .PlainInit (repoDir , false )
127139 if errInit != nil {
128140 s .dirLock .Unlock ()
129141 return fmt .Errorf ("git token store: init empty repo: %w" , errInit )
130142 }
143+ if s .branch != "" {
144+ headRef := plumbing .NewSymbolicReference (plumbing .HEAD , plumbing .NewBranchReferenceName (s .branch ))
145+ if errHead := repo .Storer .SetReference (headRef ); errHead != nil {
146+ s .dirLock .Unlock ()
147+ return fmt .Errorf ("git token store: set head to branch %s: %w" , s .branch , errHead )
148+ }
149+ }
131150 if _ , errRemote := repo .Remote ("origin" ); errRemote != nil {
132151 if _ , errCreate := repo .CreateRemote (& config.RemoteConfig {
133152 Name : "origin" ,
@@ -176,16 +195,39 @@ func (s *GitTokenStore) EnsureRepository() error {
176195 s .dirLock .Unlock ()
177196 return fmt .Errorf ("git token store: worktree: %w" , errWorktree )
178197 }
179- if errPull := worktree .Pull (& git.PullOptions {Auth : authMethod , RemoteName : "origin" }); errPull != nil {
198+ if s .branch != "" {
199+ if errCheckout := s .checkoutConfiguredBranch (repo , worktree , authMethod ); errCheckout != nil {
200+ s .dirLock .Unlock ()
201+ return errCheckout
202+ }
203+ } else {
204+ // When branch is unset, ensure the working tree follows the remote default branch
205+ if err := checkoutRemoteDefaultBranch (repo , worktree , authMethod ); err != nil {
206+ if ! shouldFallbackToCurrentBranch (repo , err ) {
207+ s .dirLock .Unlock ()
208+ return fmt .Errorf ("git token store: checkout remote default: %w" , err )
209+ }
210+ }
211+ }
212+ pullOpts := & git.PullOptions {Auth : authMethod , RemoteName : "origin" }
213+ if s .branch != "" {
214+ pullOpts .ReferenceName = plumbing .NewBranchReferenceName (s .branch )
215+ }
216+ if errPull := worktree .Pull (pullOpts ); errPull != nil {
180217 switch {
181218 case errors .Is (errPull , git .NoErrAlreadyUpToDate ),
182219 errors .Is (errPull , git .ErrUnstagedChanges ),
183220 errors .Is (errPull , git .ErrNonFastForwardUpdate ):
184221 // Ignore clean syncs, local edits, and remote divergence—local changes win.
185222 case errors .Is (errPull , transport .ErrAuthenticationRequired ),
186- errors .Is (errPull , plumbing .ErrReferenceNotFound ),
187223 errors .Is (errPull , transport .ErrEmptyRemoteRepository ):
188224 // Ignore authentication prompts and empty remote references on initial sync.
225+ case errors .Is (errPull , plumbing .ErrReferenceNotFound ):
226+ if s .branch != "" {
227+ s .dirLock .Unlock ()
228+ return fmt .Errorf ("git token store: pull: %w" , errPull )
229+ }
230+ // Ignore missing references only when following the remote default branch.
189231 default :
190232 s .dirLock .Unlock ()
191233 return fmt .Errorf ("git token store: pull: %w" , errPull )
@@ -553,6 +595,192 @@ func (s *GitTokenStore) relativeToRepo(path string) (string, error) {
553595 return rel , nil
554596}
555597
598+ func (s * GitTokenStore ) checkoutConfiguredBranch (repo * git.Repository , worktree * git.Worktree , authMethod transport.AuthMethod ) error {
599+ branchRefName := plumbing .NewBranchReferenceName (s .branch )
600+ headRef , errHead := repo .Head ()
601+ switch {
602+ case errHead == nil && headRef .Name () == branchRefName :
603+ return nil
604+ case errHead != nil && ! errors .Is (errHead , plumbing .ErrReferenceNotFound ):
605+ return fmt .Errorf ("git token store: get head: %w" , errHead )
606+ }
607+
608+ if err := worktree .Checkout (& git.CheckoutOptions {Branch : branchRefName }); err == nil {
609+ return nil
610+ } else if _ , errRef := repo .Reference (branchRefName , true ); errRef == nil {
611+ return fmt .Errorf ("git token store: checkout branch %s: %w" , s .branch , err )
612+ } else if ! errors .Is (errRef , plumbing .ErrReferenceNotFound ) {
613+ return fmt .Errorf ("git token store: inspect branch %s: %w" , s .branch , errRef )
614+ } else if err := s .checkoutConfiguredRemoteTrackingBranch (repo , worktree , branchRefName , authMethod ); err != nil {
615+ return fmt .Errorf ("git token store: checkout branch %s: %w" , s .branch , err )
616+ }
617+
618+ return nil
619+ }
620+
621+ func (s * GitTokenStore ) checkoutConfiguredRemoteTrackingBranch (repo * git.Repository , worktree * git.Worktree , branchRefName plumbing.ReferenceName , authMethod transport.AuthMethod ) error {
622+ remoteRefName := plumbing .ReferenceName ("refs/remotes/origin/" + s .branch )
623+ remoteRef , err := repo .Reference (remoteRefName , true )
624+ if errors .Is (err , plumbing .ErrReferenceNotFound ) {
625+ if errSync := syncRemoteReferences (repo , authMethod ); errSync != nil {
626+ return fmt .Errorf ("sync remote refs: %w" , errSync )
627+ }
628+ remoteRef , err = repo .Reference (remoteRefName , true )
629+ }
630+ if err != nil {
631+ return err
632+ }
633+ if err := worktree .Checkout (& git.CheckoutOptions {Branch : branchRefName , Create : true , Hash : remoteRef .Hash ()}); err != nil {
634+ return err
635+ }
636+
637+ cfg , err := repo .Config ()
638+ if err != nil {
639+ return fmt .Errorf ("git token store: repo config: %w" , err )
640+ }
641+ if _ , ok := cfg .Branches [s .branch ]; ! ok {
642+ cfg .Branches [s .branch ] = & config.Branch {Name : s .branch }
643+ }
644+ cfg .Branches [s .branch ].Remote = "origin"
645+ cfg .Branches [s .branch ].Merge = branchRefName
646+ if err := repo .SetConfig (cfg ); err != nil {
647+ return fmt .Errorf ("git token store: set branch config: %w" , err )
648+ }
649+ return nil
650+ }
651+
652+ func syncRemoteReferences (repo * git.Repository , authMethod transport.AuthMethod ) error {
653+ if err := repo .Fetch (& git.FetchOptions {Auth : authMethod , RemoteName : "origin" }); err != nil && ! errors .Is (err , git .NoErrAlreadyUpToDate ) {
654+ return err
655+ }
656+ return nil
657+ }
658+
659+ // resolveRemoteDefaultBranch queries the origin remote to determine the remote's default branch
660+ // (the target of HEAD) and returns the corresponding local branch reference name (e.g. refs/heads/master).
661+ func resolveRemoteDefaultBranch (repo * git.Repository , authMethod transport.AuthMethod ) (resolvedRemoteBranch , error ) {
662+ if err := syncRemoteReferences (repo , authMethod ); err != nil {
663+ return resolvedRemoteBranch {}, fmt .Errorf ("resolve remote default: sync remote refs: %w" , err )
664+ }
665+ remote , err := repo .Remote ("origin" )
666+ if err != nil {
667+ return resolvedRemoteBranch {}, fmt .Errorf ("resolve remote default: get remote: %w" , err )
668+ }
669+ refs , err := remote .List (& git.ListOptions {Auth : authMethod })
670+ if err != nil {
671+ if resolved , ok := resolveRemoteDefaultBranchFromLocal (repo ); ok {
672+ return resolved , nil
673+ }
674+ return resolvedRemoteBranch {}, fmt .Errorf ("resolve remote default: list remote refs: %w" , err )
675+ }
676+ for _ , r := range refs {
677+ if r .Name () == plumbing .HEAD {
678+ if r .Type () == plumbing .SymbolicReference {
679+ if target , ok := normalizeRemoteBranchReference (r .Target ()); ok {
680+ return resolvedRemoteBranch {name : target }, nil
681+ }
682+ }
683+ s := r .String ()
684+ if idx := strings .Index (s , "->" ); idx != - 1 {
685+ if target , ok := normalizeRemoteBranchReference (plumbing .ReferenceName (strings .TrimSpace (s [idx + 2 :]))); ok {
686+ return resolvedRemoteBranch {name : target }, nil
687+ }
688+ }
689+ }
690+ }
691+ if resolved , ok := resolveRemoteDefaultBranchFromLocal (repo ); ok {
692+ return resolved , nil
693+ }
694+ for _ , r := range refs {
695+ if normalized , ok := normalizeRemoteBranchReference (r .Name ()); ok {
696+ return resolvedRemoteBranch {name : normalized , hash : r .Hash ()}, nil
697+ }
698+ }
699+ return resolvedRemoteBranch {}, fmt .Errorf ("resolve remote default: remote default branch not found" )
700+ }
701+
702+ func resolveRemoteDefaultBranchFromLocal (repo * git.Repository ) (resolvedRemoteBranch , bool ) {
703+ ref , err := repo .Reference (plumbing .ReferenceName ("refs/remotes/origin/HEAD" ), true )
704+ if err != nil || ref .Type () != plumbing .SymbolicReference {
705+ return resolvedRemoteBranch {}, false
706+ }
707+ target , ok := normalizeRemoteBranchReference (ref .Target ())
708+ if ! ok {
709+ return resolvedRemoteBranch {}, false
710+ }
711+ return resolvedRemoteBranch {name : target }, true
712+ }
713+
714+ func normalizeRemoteBranchReference (name plumbing.ReferenceName ) (plumbing.ReferenceName , bool ) {
715+ switch {
716+ case strings .HasPrefix (name .String (), "refs/heads/" ):
717+ return name , true
718+ case strings .HasPrefix (name .String (), "refs/remotes/origin/" ):
719+ return plumbing .NewBranchReferenceName (strings .TrimPrefix (name .String (), "refs/remotes/origin/" )), true
720+ default :
721+ return "" , false
722+ }
723+ }
724+
725+ func shouldFallbackToCurrentBranch (repo * git.Repository , err error ) bool {
726+ if ! errors .Is (err , transport .ErrAuthenticationRequired ) && ! errors .Is (err , transport .ErrEmptyRemoteRepository ) {
727+ return false
728+ }
729+ _ , headErr := repo .Head ()
730+ return headErr == nil
731+ }
732+
733+ // checkoutRemoteDefaultBranch ensures the working tree is checked out to the remote's default branch
734+ // (the branch target of origin/HEAD). If the local branch does not exist it will be created to track
735+ // the remote branch.
736+ func checkoutRemoteDefaultBranch (repo * git.Repository , worktree * git.Worktree , authMethod transport.AuthMethod ) error {
737+ resolved , err := resolveRemoteDefaultBranch (repo , authMethod )
738+ if err != nil {
739+ return err
740+ }
741+ branchRefName := resolved .name
742+ // If HEAD already points to the desired branch, nothing to do.
743+ headRef , errHead := repo .Head ()
744+ if errHead == nil && headRef .Name () == branchRefName {
745+ return nil
746+ }
747+ // If local branch exists, attempt a checkout
748+ if _ , err := repo .Reference (branchRefName , true ); err == nil {
749+ if err := worktree .Checkout (& git.CheckoutOptions {Branch : branchRefName }); err != nil {
750+ return fmt .Errorf ("checkout branch %s: %w" , branchRefName .String (), err )
751+ }
752+ return nil
753+ }
754+ // Try to find the corresponding remote tracking ref (refs/remotes/origin/<name>)
755+ branchShort := strings .TrimPrefix (branchRefName .String (), "refs/heads/" )
756+ remoteRefName := plumbing .ReferenceName ("refs/remotes/origin/" + branchShort )
757+ hash := resolved .hash
758+ if remoteRef , err := repo .Reference (remoteRefName , true ); err == nil {
759+ hash = remoteRef .Hash ()
760+ } else if err != nil && ! errors .Is (err , plumbing .ErrReferenceNotFound ) {
761+ return fmt .Errorf ("checkout remote default: remote ref %s: %w" , remoteRefName .String (), err )
762+ }
763+ if hash == plumbing .ZeroHash {
764+ return fmt .Errorf ("checkout remote default: remote ref %s not found" , remoteRefName .String ())
765+ }
766+ if err := worktree .Checkout (& git.CheckoutOptions {Branch : branchRefName , Create : true , Hash : hash }); err != nil {
767+ return fmt .Errorf ("checkout create branch %s: %w" , branchRefName .String (), err )
768+ }
769+ cfg , err := repo .Config ()
770+ if err != nil {
771+ return fmt .Errorf ("git token store: repo config: %w" , err )
772+ }
773+ if _ , ok := cfg .Branches [branchShort ]; ! ok {
774+ cfg .Branches [branchShort ] = & config.Branch {Name : branchShort }
775+ }
776+ cfg .Branches [branchShort ].Remote = "origin"
777+ cfg .Branches [branchShort ].Merge = branchRefName
778+ if err := repo .SetConfig (cfg ); err != nil {
779+ return fmt .Errorf ("git token store: set branch config: %w" , err )
780+ }
781+ return nil
782+ }
783+
556784func (s * GitTokenStore ) commitAndPushLocked (message string , relPaths ... string ) error {
557785 repoDir := s .repoDirSnapshot ()
558786 if repoDir == "" {
@@ -618,7 +846,16 @@ func (s *GitTokenStore) commitAndPushLocked(message string, relPaths ...string)
618846 return errRewrite
619847 }
620848 s .maybeRunGC (repo )
621- if err = repo .Push (& git.PushOptions {Auth : s .gitAuth (), Force : true }); err != nil {
849+ pushOpts := & git.PushOptions {Auth : s .gitAuth (), Force : true }
850+ if s .branch != "" {
851+ pushOpts .RefSpecs = []config.RefSpec {config .RefSpec ("refs/heads/" + s .branch + ":refs/heads/" + s .branch )}
852+ } else {
853+ // When branch is unset, pin push to the currently checked-out branch.
854+ if headRef , err := repo .Head (); err == nil {
855+ pushOpts .RefSpecs = []config.RefSpec {config .RefSpec (headRef .Name ().String () + ":" + headRef .Name ().String ())}
856+ }
857+ }
858+ if err = repo .Push (pushOpts ); err != nil {
622859 if errors .Is (err , git .NoErrAlreadyUpToDate ) {
623860 return nil
624861 }
0 commit comments